Initial commit: WebGPU portfolio — generative-art site with DOM overlay
This commit is contained in:
147
src/app.ts
Normal file
147
src/app.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Renderer, UiState } from './engine/renderer'
|
||||
import { Input } from './ui/input'
|
||||
import { DomOverlay } from './ui/overlay'
|
||||
|
||||
const DPR_CAP = 2
|
||||
|
||||
interface SceneContent {
|
||||
title: string
|
||||
subtitle: string
|
||||
body: string
|
||||
}
|
||||
|
||||
const SCENES: SceneContent[] = [
|
||||
{
|
||||
title: 'Ahmed A. Mohamed',
|
||||
subtitle: 'Creative Developer & Graphics Engineer',
|
||||
body: 'Welcome to my personal website, there\'s a lot going on in my life that I will be sharing here randomly over time so take a look.'
|
||||
},
|
||||
{
|
||||
title: 'ABOUT',
|
||||
subtitle: 'Who I am',
|
||||
body: 'I build creative web experiences\nusing cutting-edge graphics technology.'
|
||||
},
|
||||
{
|
||||
title: 'PROJECTS',
|
||||
subtitle: 'Things I have built',
|
||||
body: 'A collection of work exploring\nWebGPU, shaders, and generative art.'
|
||||
},
|
||||
{
|
||||
title: 'CONTACT',
|
||||
subtitle: 'Get in touch',
|
||||
body: 'Reach me at hello@example.com\nor find me on GitHub.'
|
||||
},
|
||||
{
|
||||
title: 'RESUME',
|
||||
subtitle: 'Ahmed Mohamed',
|
||||
body: 'Graphics & Systems Engineer'
|
||||
}
|
||||
]
|
||||
|
||||
export class App {
|
||||
private canvas: HTMLCanvasElement
|
||||
private renderer = new Renderer()
|
||||
private input!: Input
|
||||
private overlay!: DomOverlay
|
||||
|
||||
private dpr = 1
|
||||
private res = new Float32Array(2)
|
||||
private mouse = new Float32Array(2)
|
||||
|
||||
private scene = 0
|
||||
private prevScene = -1
|
||||
private startTime = 0
|
||||
private rafId = 0
|
||||
private running = false
|
||||
private stemMultipliers = new Float32Array(32)
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
this.input = new Input()
|
||||
this.overlay = new DomOverlay(SCENES, {
|
||||
onNavClick: (s) => { this.scene = s },
|
||||
onButtonClick: () => { this.scene = (this.scene + 1) % 5 }
|
||||
})
|
||||
this.overlay.setScene(this.scene)
|
||||
this.resize()
|
||||
const ok = await this.renderer.init(this.canvas)
|
||||
if (!ok) return false
|
||||
this.startTime = performance.now()
|
||||
this.running = true
|
||||
this.loop()
|
||||
return true
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false
|
||||
cancelAnimationFrame(this.rafId)
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
this.dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
|
||||
const w = Math.floor(window.innerWidth * this.dpr)
|
||||
const h = Math.floor(window.innerHeight * this.dpr)
|
||||
if (this.canvas.width !== w || this.canvas.height !== h) {
|
||||
this.canvas.width = w
|
||||
this.canvas.height = h
|
||||
this.res[0] = w
|
||||
this.res[1] = h
|
||||
if (this.renderer.ready) this.renderer.resize()
|
||||
}
|
||||
}
|
||||
|
||||
private computeStemMultipliers(time: number): void {
|
||||
const sm = this.stemMultipliers
|
||||
// scene-based color identity and speed
|
||||
const colorBase = [0.0, 0.6, 1.2, 1.8, 2.4]
|
||||
const speedBase = [0.0, 0.2, 0.1, 0.0, 0.15]
|
||||
const scene = this.scene
|
||||
|
||||
// [0] = (camera speed, 0, color offset, twist/flash)
|
||||
sm[0] = speedBase[scene]
|
||||
sm[2] = colorBase[scene] * 0.2
|
||||
sm[3] = 0.3 + 0.3 * Math.sin(time * 0.5)
|
||||
|
||||
// [1] = (color offset, 0, 0, 0)
|
||||
sm[4] = colorBase[scene] * 0.4
|
||||
|
||||
// [2] = (size scale, 0, 0, 0)
|
||||
sm[8] = 0.1// + 0.4 * Math.sin(time * 0.3 + scene * 3.0)
|
||||
|
||||
// rest of the array stays zero
|
||||
}
|
||||
|
||||
private loop = (): void => {
|
||||
if (!this.running) return
|
||||
this.rafId = requestAnimationFrame(this.loop)
|
||||
|
||||
const now = performance.now()
|
||||
const time = (now - this.startTime) / 1000
|
||||
|
||||
this.resize()
|
||||
|
||||
const mx = this.input.x * this.dpr
|
||||
const my = this.res[1] - this.input.y * this.dpr
|
||||
this.mouse[0] = mx
|
||||
this.mouse[1] = my
|
||||
|
||||
if (this.scene !== this.prevScene) {
|
||||
this.prevScene = this.scene
|
||||
this.overlay.setScene(this.scene)
|
||||
}
|
||||
|
||||
this.computeStemMultipliers(time)
|
||||
|
||||
const state: UiState = {
|
||||
resolution: this.res,
|
||||
time,
|
||||
scene: this.scene,
|
||||
mouse: this.mouse,
|
||||
stemMultipliers: this.stemMultipliers
|
||||
}
|
||||
this.renderer.render(state)
|
||||
}
|
||||
}
|
||||
18
src/data/projects/auv-simulation.ts
Normal file
18
src/data/projects/auv-simulation.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ProjectPage } from './index'
|
||||
|
||||
const page: ProjectPage = {
|
||||
id: 'auv-simulation',
|
||||
name: 'AUV Simulation & Interfacing',
|
||||
tagline: 'Full physics simulation + ROS + Qt ground control',
|
||||
date: '2021',
|
||||
tech: ['C++', 'ROS', 'Gazebo', 'Qt5'],
|
||||
content: `
|
||||
<p>Full AUV physics simulation in Gazebo with ROS nodes using GStreamer to pipeline
|
||||
media from AUV/simulation to an external monitoring interface via a Qt application
|
||||
for the ground control team.</p>
|
||||
|
||||
<p>Designed the simulator, developed the Gazebo plugins and the C++ ROS nodes.</p>
|
||||
`,
|
||||
}
|
||||
|
||||
export default page
|
||||
20
src/data/projects/evol-engine.ts
Normal file
20
src/data/projects/evol-engine.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ProjectPage } from './index'
|
||||
|
||||
const page: ProjectPage = {
|
||||
id: 'evol-engine',
|
||||
name: 'Evol Game Engine',
|
||||
tagline: 'Graduation Project — Vulkan-powered engine',
|
||||
date: '07/2021',
|
||||
tech: ['C', 'C++', 'Vulkan', 'OpenGL', 'ImGUI'],
|
||||
content: `
|
||||
<p>Developed a high-performance, Vulkan-powered game engine with real-time physics
|
||||
simulation, applicable to vehicle simulation and digital twin technology.</p>
|
||||
|
||||
<p>Built a modular scene editor for real-time rendering and physics-based simulation.</p>
|
||||
|
||||
<p>Implemented the Vulkan renderer for the engine core and an OpenGL PBR renderer
|
||||
for the editor.</p>
|
||||
`,
|
||||
}
|
||||
|
||||
export default page
|
||||
17
src/data/projects/improv-gfx.ts
Normal file
17
src/data/projects/improv-gfx.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ProjectPage } from './index'
|
||||
|
||||
const page: ProjectPage = {
|
||||
id: 'improv-gfx',
|
||||
name: 'ImprovGFX',
|
||||
tagline: 'From-scratch rasterizer with OpenCL GPU acceleration',
|
||||
date: '2021',
|
||||
tech: ['C++', 'OpenCL'],
|
||||
content: `
|
||||
<p>A renderer/rasterizer built from scratch to render .obj models and .tga textures,
|
||||
with OpenCL providing GPU acceleration.</p>
|
||||
|
||||
<p>Built a clean API for constructing scenes and shaders.</p>
|
||||
`,
|
||||
}
|
||||
|
||||
export default page
|
||||
66
src/data/projects/index.ts
Normal file
66
src/data/projects/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// ---------- types ----------
|
||||
|
||||
export interface ProjectPage {
|
||||
id: string
|
||||
name: string
|
||||
tagline: string
|
||||
date: string
|
||||
tech: string[]
|
||||
content: string // free-form HTML
|
||||
}
|
||||
|
||||
export interface ProjectCategory {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
projects: ProjectPage[]
|
||||
}
|
||||
|
||||
// ---------- project files ----------
|
||||
|
||||
import radianceCaching from './radiance-caching'
|
||||
import pointCloud from './point-cloud'
|
||||
import evolEngine from './evol-engine'
|
||||
import auvSimulation from './auv-simulation'
|
||||
import improvGfx from './improv-gfx'
|
||||
|
||||
// ---------- flat list (for lookups) ----------
|
||||
|
||||
export const PROJECTS: ProjectPage[] = [
|
||||
radianceCaching,
|
||||
pointCloud,
|
||||
evolEngine,
|
||||
auvSimulation,
|
||||
improvGfx,
|
||||
]
|
||||
|
||||
// ---------- categories ----------
|
||||
// Add new categories here. Projects go in the `projects` array.
|
||||
// Empty categories get a placeholder card on the grid.
|
||||
|
||||
export const CATEGORIES: ProjectCategory[] = [
|
||||
{
|
||||
id: 'programming',
|
||||
label: 'Programming',
|
||||
description: 'Graphics, engines, tools, and low-level systems.',
|
||||
projects: [radianceCaching, pointCloud, evolEngine, improvGfx, auvSimulation],
|
||||
},
|
||||
{
|
||||
id: 'hardware',
|
||||
label: 'Hardware',
|
||||
description: 'Embedded, FPGA, PCB, and physical builds.',
|
||||
projects: [],
|
||||
},
|
||||
{
|
||||
id: 'visuals',
|
||||
label: 'Visuals',
|
||||
description: 'Shaders, demoscene, generative art, and rendering experiments.',
|
||||
projects: [],
|
||||
},
|
||||
{
|
||||
id: 'music',
|
||||
label: 'Music',
|
||||
description: 'Production, sound design, tools, and performances.',
|
||||
projects: [],
|
||||
},
|
||||
]
|
||||
24
src/data/projects/point-cloud.ts
Normal file
24
src/data/projects/point-cloud.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ProjectPage } from './index'
|
||||
|
||||
const page: ProjectPage = {
|
||||
id: 'point-cloud',
|
||||
name: 'GPU-Driven Point Cloud Renderer',
|
||||
tagline: 'TUM — 140M+ points at ~100 FPS',
|
||||
date: '2024',
|
||||
tech: ['C++', 'Vulkan', 'GLSL'],
|
||||
content: `
|
||||
<p>Implemented a real-time GPU-driven renderer for large point clouds (140M+ points
|
||||
at ~100 FPS) using a custom 5-stage compute shader pipeline in Vulkan, based on
|
||||
Schütz et al. (2021, 2022).</p>
|
||||
|
||||
<p>Created a software rasterization pipeline using atomic uint64 depth-color packing
|
||||
and compute shaders, enabling fine-grained depth testing without a hardware depth
|
||||
buffer.</p>
|
||||
|
||||
<p>Implemented batch-based frustum culling, Morton-code spatial sorting, and
|
||||
screen-coverage LOD selection via 10-bit quantized position precision. Used Vulkan
|
||||
subgroup extensions for warp-level optimizations.</p>
|
||||
`,
|
||||
}
|
||||
|
||||
export default page
|
||||
21
src/data/projects/radiance-caching.ts
Normal file
21
src/data/projects/radiance-caching.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ProjectPage } from './index'
|
||||
|
||||
const page: ProjectPage = {
|
||||
id: 'radiance-caching',
|
||||
name: 'Radiance Caching for Real-Time Volumetric Rendering',
|
||||
tagline: "Master's Thesis — TUM",
|
||||
date: '12/2025 — 06/2026',
|
||||
tech: ['C++', 'CUDA', 'Vulkan', 'OptiX'],
|
||||
content: `
|
||||
<p>Researched and implemented a real-time radiance cache for volumetric path tracing
|
||||
using 3D Gaussian Splatting and a fused MLP as the cache representation.</p>
|
||||
|
||||
<p>Developed an online differentiable optimization pipeline in CUDA that adapts the
|
||||
Gaussian cache to dynamic lighting and transfer function changes during rendering.</p>
|
||||
|
||||
<p>Built a spatial-hash-based fused MLP for radiance caching on volumetric
|
||||
heterogeneous media. Supervised by Prof. Westermann.</p>
|
||||
`,
|
||||
}
|
||||
|
||||
export default page
|
||||
131
src/data/resume.ts
Normal file
131
src/data/resume.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// ---------- resume data types ----------
|
||||
|
||||
export interface ResumeEntry {
|
||||
role: string
|
||||
company: string
|
||||
location: string
|
||||
date: string
|
||||
bullets: string[]
|
||||
thesis?: string // education only
|
||||
}
|
||||
|
||||
export interface SkillGroup {
|
||||
label: string
|
||||
skills: string[]
|
||||
}
|
||||
|
||||
export interface ResumeData {
|
||||
name: string
|
||||
contact: { label: string; href?: string }[]
|
||||
location: string
|
||||
summary: string
|
||||
education: ResumeEntry[]
|
||||
work: ResumeEntry[]
|
||||
skills: SkillGroup[]
|
||||
}
|
||||
|
||||
// ---------- resume content ----------
|
||||
|
||||
export const RESUME: ResumeData = {
|
||||
name: 'Ahmed Mohamed',
|
||||
contact: [
|
||||
{ label: '(+49) 15202103583' },
|
||||
{ label: 'ed.mohamed@tum.de', href: 'mailto:ed.mohamed@tum.de' },
|
||||
{ label: 'linkedin.com/in/inedited/', href: 'https://linkedin.com/in/inedited/' },
|
||||
{ label: 'github.com/inedited', href: 'https://github.com/inedited' },
|
||||
],
|
||||
location: 'Gustav-Schwab-Str., 4, 81673 München, Germany',
|
||||
summary:
|
||||
'Experienced software developer with a strong background in graphics programming, real-time rendering, XR development and AI engineering. Experienced in developing high-performance applications and specializing in low-level optimizations.',
|
||||
education: [
|
||||
{
|
||||
role: 'Master of Science in Informatics: Games Engineering',
|
||||
company: 'Technical University of Munich (TUM)',
|
||||
location: 'Munich, Germany',
|
||||
date: '04/2024 \u2014 06/2026',
|
||||
thesis:
|
||||
'Thesis: Real-time volume path tracing using neural radiance caching and 3DGS Radiance cache.',
|
||||
bullets: [],
|
||||
},
|
||||
{
|
||||
role: 'Bachelor of Science in Software Systems and Computer Engineering',
|
||||
company: 'Ain Shams University',
|
||||
location: 'Cairo, Egypt',
|
||||
date: '07/2016 \u2014 07/2021',
|
||||
thesis: 'Thesis: 3D game engine in C.',
|
||||
bullets: [],
|
||||
},
|
||||
{
|
||||
role: 'Dual Bachelor of Science in Software Systems and Computer Engineering',
|
||||
company: 'University of East London',
|
||||
location: 'London, United Kingdom',
|
||||
date: '07/2018 \u2014 07/2021',
|
||||
bullets: [],
|
||||
},
|
||||
],
|
||||
work: [
|
||||
{
|
||||
role: 'Software Developer (Working Student)',
|
||||
company: 'Siemens AG',
|
||||
location: 'Munich, Germany',
|
||||
date: '04/2025 \u2014 Present',
|
||||
bullets: [
|
||||
'Led full cycle development across Physical AI, simulation, XR, and robotics integration projects within the Industrial Metaverse Lab.',
|
||||
'Worked on technical collaboration projects with NVIDIA, Meta, and Amazon AWS for Industrial AI applications.',
|
||||
'Built XR applications for Meta Quest, Meta RayBan, and Apple Vision Pro, implementing real time XR AI assisted solutions for industrial environments.',
|
||||
'Worked on robotics simulation and training using NVIDIA IsaacSim, developed ROS integrations, Teleoperation protocols, and deployed trained models to physical robots.',
|
||||
'Deployed and fine-tuned LLM models for industrial applications and internal tooling.',
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'Software Developer',
|
||||
company: 'The Forge ConfettiFX',
|
||||
location: 'California (Remote), USA',
|
||||
date: '03/2022 \u2014 08/2023',
|
||||
bullets: [
|
||||
'Contributed to real-time rendering engine for <em>Forza Motorsport 8</em> with Turn10 studio, optimizing rendering performance and rewriting shadow rendering system and asset pipelines.',
|
||||
'Improved The Forge rendering framework visibility buffer implementation, optimizing the graphics pipeline for real time rendering and testing across different consoles.',
|
||||
'Developed and optimized Linux ports for The Forge rendering framework and optimized the framework for Steam Deck.',
|
||||
'Redesigned the CI/CD testing pipeline for a Linux-based rendering framework, reducing testing time.',
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'Software Developer',
|
||||
company: 'SciChart',
|
||||
location: 'London (Remote), United Kingdom',
|
||||
date: '01/2022 \u2014 01/2025',
|
||||
bullets: [
|
||||
'Developed and optimized the SciChart 3D graphics engine for high performance data visualization, simulation, telemetry, and real time analytics.',
|
||||
'Ported the 3D graphics engine and WPF Desktop application to Linux.',
|
||||
'Worked on the development of JavaScript/WebGL version of the 3D graphics engine on top of the C++ core engine.',
|
||||
'Implemented a Vulkan rendering backend, increasing performance on large scale datasets by about 20% across multiple platforms.',
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'Software Developer',
|
||||
company: '412 Labs',
|
||||
location: 'Cairo, Egypt',
|
||||
date: '03/2021 \u2014 12/2021',
|
||||
bullets: [
|
||||
'Developed VR applications using Unity for different client projects for architectural visualization and internal designs.',
|
||||
'Taught a class of 20 students on VR development fundamentals using Unity.',
|
||||
'Built and deployed multiple full stack solutions for 412 Labs projects and clients.',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
skills: [
|
||||
{
|
||||
label: 'Languages',
|
||||
skills: ['C', 'C++', 'C#', 'Python','Swift','TypeScript','Kotlin', 'GLSL', 'HLSL'],
|
||||
},
|
||||
{
|
||||
label: 'Graphics & HPC',
|
||||
skills: ['Vulkan', 'OpenGL', 'DirectX', 'CUDA', 'WebGpu', 'Volume Rendering', 'Path Tracing'],
|
||||
},
|
||||
{
|
||||
label: 'Other',
|
||||
skills: ['CI/CD', 'AI Agents', 'LLM', 'ROS', 'Unity', 'Unreal Engine', 'Git', 'Docker'],
|
||||
},
|
||||
],
|
||||
}
|
||||
104
src/engine/renderer.ts
Normal file
104
src/engine/renderer.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import shaderSource from './shaders/fullscreen.wgsl?raw'
|
||||
|
||||
const UNIFORM_FLOATS = 48 // res(2), time, scene, mouse(2), pad(2), date(4), sampleRate(4), stemMultipliers(32)
|
||||
const UNIFORM_BYTES = UNIFORM_FLOATS * 4
|
||||
|
||||
export interface UiState {
|
||||
resolution: Float32Array
|
||||
time: number
|
||||
scene: number
|
||||
mouse: Float32Array
|
||||
stemMultipliers: Float32Array
|
||||
}
|
||||
|
||||
export class Renderer {
|
||||
private device!: GPUDevice
|
||||
private context!: GPUCanvasContext
|
||||
private format!: GPUTextureFormat
|
||||
private pipeline!: GPURenderPipeline
|
||||
private uniformBuffer!: GPUBuffer
|
||||
private bindGroup!: GPUBindGroup
|
||||
private uniformData = new Float32Array(UNIFORM_FLOATS)
|
||||
private configured = false
|
||||
|
||||
get ready(): boolean {
|
||||
return this.configured
|
||||
}
|
||||
|
||||
async init(canvas: HTMLCanvasElement): Promise<boolean> {
|
||||
if (!navigator.gpu) return false
|
||||
const adapter = await navigator.gpu.requestAdapter()
|
||||
if (!adapter) return false
|
||||
this.device = await adapter.requestDevice()
|
||||
this.context = canvas.getContext('webgpu') as GPUCanvasContext
|
||||
this.format = navigator.gpu.getPreferredCanvasFormat()
|
||||
this.configure()
|
||||
|
||||
const module = this.device.createShaderModule({ code: shaderSource })
|
||||
this.pipeline = this.device.createRenderPipeline({
|
||||
layout: 'auto',
|
||||
vertex: { module, entryPoint: 'vs' },
|
||||
fragment: { module, entryPoint: 'fs', targets: [{ format: this.format }] },
|
||||
primitive: { topology: 'triangle-list' }
|
||||
})
|
||||
|
||||
this.uniformBuffer = this.device.createBuffer({
|
||||
size: UNIFORM_BYTES,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
||||
})
|
||||
this.bindGroup = this.device.createBindGroup({
|
||||
layout: this.pipeline.getBindGroupLayout(0),
|
||||
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }]
|
||||
})
|
||||
|
||||
this.device.lost.then((info) => { console.error('WebGPU device lost:', info); this.configured = false })
|
||||
this.device.onuncapturederror = (ev: GPUUncapturedErrorEvent) => {
|
||||
console.error('WebGPU uncaptured error:', ev.error)
|
||||
}
|
||||
this.configured = true
|
||||
return true
|
||||
}
|
||||
|
||||
configure(): void {
|
||||
this.context.configure({
|
||||
device: this.device,
|
||||
format: this.format,
|
||||
alphaMode: 'opaque'
|
||||
})
|
||||
}
|
||||
|
||||
resize(): void {
|
||||
this.configure()
|
||||
}
|
||||
|
||||
render(state: UiState): void {
|
||||
if (!this.configured) return
|
||||
const d = this.uniformData
|
||||
d[0] = state.resolution[0]
|
||||
d[1] = state.resolution[1]
|
||||
d[2] = state.time
|
||||
d[3] = state.scene
|
||||
d[4] = state.mouse[0]
|
||||
d[5] = state.mouse[1]
|
||||
// d[6], d[7]: padding — left as zero
|
||||
// d[8]-d[11]: date — left as zero
|
||||
// d[12]-d[15]: sampleRate — left as zero
|
||||
d.set(state.stemMultipliers, 16)
|
||||
this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData)
|
||||
|
||||
const encoder = this.device.createCommandEncoder()
|
||||
const view = this.context.getCurrentTexture().createView()
|
||||
|
||||
const bgPass = encoder.beginRenderPass({
|
||||
colorAttachments: [
|
||||
{ view, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }
|
||||
]
|
||||
})
|
||||
bgPass.setPipeline(this.pipeline)
|
||||
bgPass.setBindGroup(0, this.bindGroup)
|
||||
bgPass.draw(3)
|
||||
bgPass.end()
|
||||
|
||||
this.device.queue.submit([encoder.finish()])
|
||||
}
|
||||
}
|
||||
104
src/engine/shaders/fullscreen.wgsl
Normal file
104
src/engine/shaders/fullscreen.wgsl
Normal file
@@ -0,0 +1,104 @@
|
||||
struct Uniforms {
|
||||
resolution: vec2<f32>,
|
||||
time: f32,
|
||||
scene: f32,
|
||||
mouse: vec2<f32>,
|
||||
date: vec4<f32>,
|
||||
sampleRate: vec4<f32>,
|
||||
stemMultipliers: array<vec4<f32>, 8>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) i: u32) -> VsOut {
|
||||
let p = array<vec2<f32>, 3>(
|
||||
vec2<f32>(-1.0, -3.0),
|
||||
vec2<f32>(-1.0, 1.0),
|
||||
vec2<f32>(3.0, 1.0),
|
||||
);
|
||||
let clip = p[i];
|
||||
var out: VsOut;
|
||||
out.pos = vec4<f32>(clip, 0.0, 1.0);
|
||||
out.uv = clip;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(in: VsOut) -> @location(0) vec4<f32> {
|
||||
let res = u.resolution;
|
||||
let fragCoord = (in.uv * vec2<f32>(0.5) + vec2<f32>(0.5)) * res;
|
||||
|
||||
var uv = (fragCoord - 0.5 * res) / res.y;
|
||||
|
||||
let slowTime = u.time * 0.2;
|
||||
|
||||
let bgGrey = vec3<f32>(0.11, 0.11, 0.12);
|
||||
let cardGrey = vec3<f32>(0.30, 0.32, 0.36);
|
||||
let coreWhite = vec3<f32>(0.90, 0.95, 0.90);
|
||||
|
||||
// ---------- palette keyed by scene ----------
|
||||
let scene0 = vec3<f32>(0.20, 0.75, 0.20); // HERO – emerald
|
||||
let scene1 = vec3<f32>(0.85, 0.55, 0.20); // ABOUT – amber
|
||||
let scene2 = vec3<f32>(0.25, 0.60, 0.95); // PROJECTS – cobalt
|
||||
let scene3 = vec3<f32>(0.90, 0.35, 0.55); // CONTACT – rose
|
||||
|
||||
let sFloor = i32(clamp(floor(u.scene), 0.0, 3.0));
|
||||
let sCeil = i32(clamp(ceil(u.scene), 0.0, 3.0));
|
||||
var c0 = scene0;
|
||||
c0 = select(c0, scene1, sFloor == 1);
|
||||
c0 = select(c0, scene2, sFloor == 2);
|
||||
c0 = select(c0, scene3, sFloor == 3);
|
||||
var c1 = scene0;
|
||||
c1 = select(c1, scene1, sCeil == 1);
|
||||
c1 = select(c1, scene2, sCeil == 2);
|
||||
c1 = select(c1, scene3, sCeil == 3);
|
||||
let tBlend = u.scene - floor(u.scene);
|
||||
let sceneAccent = mix(c0, c1, tBlend * tBlend * (3.0 - 2.0 * tBlend));
|
||||
|
||||
// ---------- mouse-reactive distortion ----------
|
||||
let mousePos = (u.mouse - 0.5 * res) / res.y;
|
||||
let distMouse = length(uv - mousePos);
|
||||
let influence = exp(-distMouse * 7.0);
|
||||
uv += (mousePos - uv) * influence * 0.12;
|
||||
|
||||
let angle = atan2(uv.y, uv.x);
|
||||
let radius = length(uv);
|
||||
|
||||
let baseSpiral = angle + (radius * 8.0) - slowTime;
|
||||
let ripple = sin(radius * 15.0 - (u.time * 0.5)) * 0.08;
|
||||
let distortedSpiral = baseSpiral + ripple;
|
||||
|
||||
let armCount = 2.0;
|
||||
let rChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral + 0.04) * armCount)));
|
||||
let gChannel = smoothstep(0.05, 0.55, abs(sin(distortedSpiral * armCount)));
|
||||
let bChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral - 0.04) * armCount)));
|
||||
let patternChannels = vec3<f32>(rChannel, gChannel, bChannel);
|
||||
|
||||
let coreNeon = vec3<f32>(0.02 / (patternChannels + 0.025));
|
||||
let spiralColor = mix(cardGrey, sceneAccent, patternChannels.g);
|
||||
let primarySpiral = coreNeon * spiralColor;
|
||||
|
||||
let ringWave = sin(radius * 12.0 - (u.time * 1.5));
|
||||
let sharpRing = smoothstep(0.85, 0.95, ringWave);
|
||||
let ringMask = sharpRing * smoothstep(0.7, 0.1, radius);
|
||||
let secondaryRings = cardGrey * ringMask * 0.4;
|
||||
|
||||
let vignette = smoothstep(0.95, 0.4, radius);
|
||||
|
||||
var finalColor = bgGrey + secondaryRings;
|
||||
finalColor = mix(finalColor, primarySpiral, 0.85);
|
||||
|
||||
let centerLight = 0.015 / (radius + 0.02);
|
||||
finalColor += centerLight * coreWhite;
|
||||
|
||||
// soft cursor glow
|
||||
finalColor += exp(-distMouse * 4.0) * 0.03 * sceneAccent;
|
||||
|
||||
return vec4<f32>(finalColor * vignette, 1.0);
|
||||
}
|
||||
352
src/engine/text/slug-generator.ts
Normal file
352
src/engine/text/slug-generator.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import type { Font, Glyph, PathCommand } from 'opentype.js'
|
||||
|
||||
const TEXTURE_WIDTH = 4096
|
||||
|
||||
export interface CodePointData {
|
||||
codePoint: number
|
||||
width: number
|
||||
height: number
|
||||
advanceWidth: number
|
||||
bearingX: number
|
||||
bearingY: number
|
||||
bandCount: number
|
||||
bandDimX: number
|
||||
bandDimY: number
|
||||
bandsTexCoordX: number
|
||||
bandsTexCoordY: number
|
||||
}
|
||||
|
||||
export interface SlugData {
|
||||
codePoints: Map<number, CodePointData>
|
||||
curvesData: Float32Array
|
||||
bandsData: Uint32Array
|
||||
curvesWidth: number
|
||||
curvesHeight: number
|
||||
bandsWidth: number
|
||||
bandsHeight: number
|
||||
ascender: number
|
||||
descender: number
|
||||
lineGap: number
|
||||
unitsPerEm: number
|
||||
}
|
||||
|
||||
interface Curve {
|
||||
first: boolean
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
x3: number
|
||||
y3: number
|
||||
texelIndex: number
|
||||
}
|
||||
|
||||
export interface GeneratorOptions {
|
||||
bandCount?: number
|
||||
fullRange?: boolean
|
||||
whitelist?: number[] | null
|
||||
}
|
||||
|
||||
export class SlugGenerator {
|
||||
private bandCount: number
|
||||
private fullRange: boolean
|
||||
private whitelist: number[] | null
|
||||
|
||||
constructor(options: GeneratorOptions = {}) {
|
||||
this.bandCount = options.bandCount ?? 16
|
||||
this.fullRange = options.fullRange ?? false
|
||||
this.whitelist = options.whitelist ?? null
|
||||
}
|
||||
|
||||
async generateFromUrl(url: string): Promise<SlugData> {
|
||||
const opentype = await import('opentype.js')
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`Failed to fetch font: ${response.status}`)
|
||||
const buffer = await response.arrayBuffer()
|
||||
const font = opentype.parse(buffer)
|
||||
return this.generate(font)
|
||||
}
|
||||
|
||||
async generateFromBuffer(buffer: ArrayBuffer): Promise<SlugData> {
|
||||
const opentype = await import('opentype.js')
|
||||
const font = opentype.parse(buffer)
|
||||
return this.generate(font)
|
||||
}
|
||||
|
||||
generate(font: Font): SlugData {
|
||||
const curvesTexData: number[] = []
|
||||
const bandsTexBandOffsets: number[] = []
|
||||
const bandsTexCurveOffsets: number[] = []
|
||||
const codePointsData: CodePointData[] = []
|
||||
|
||||
for (let i = 0; i < font.glyphs.length; i++) {
|
||||
const glyph: Glyph = font.glyphs.get(i)
|
||||
let cp = glyph.unicode
|
||||
if (cp === undefined) {
|
||||
if (i === 0) cp = -1
|
||||
else continue
|
||||
}
|
||||
|
||||
if (!this.fullRange && i !== 0) {
|
||||
if (this.whitelist) {
|
||||
if (!this.whitelist.includes(cp)) continue
|
||||
} else if (cp < 32 || cp > 126) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const path = glyph.path
|
||||
const bbox = glyph.getBoundingBox()
|
||||
if (bbox.x1 === bbox.x2 || bbox.y1 === bbox.y2) {
|
||||
codePointsData.push({
|
||||
codePoint: cp,
|
||||
width: 0,
|
||||
height: 0,
|
||||
advanceWidth: Math.floor(glyph.advanceWidth || 0),
|
||||
bearingX: 0,
|
||||
bearingY: 0,
|
||||
bandCount: 0,
|
||||
bandDimX: 0,
|
||||
bandDimY: 0,
|
||||
bandsTexCoordX: 0,
|
||||
bandsTexCoordY: 0
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const gx1 = bbox.x1
|
||||
const gy1 = bbox.y1
|
||||
const gx2 = bbox.x2
|
||||
const gy2 = bbox.y2
|
||||
|
||||
const curves = this.buildCurves(path.commands, gx1, gy1)
|
||||
if (!curves || curves.length === 0) continue
|
||||
|
||||
this.fixDegenerateCurves(curves)
|
||||
|
||||
const bandsTexelIndex = Math.floor(bandsTexBandOffsets.length / 2)
|
||||
|
||||
this.packCurves(curves, curvesTexData)
|
||||
|
||||
const sizeX = 1 + (gx2 - gx1)
|
||||
const sizeY = 1 + (gy2 - gy1)
|
||||
let bCount = this.bandCount
|
||||
if (sizeX < bCount || sizeY < bCount) {
|
||||
bCount = Math.floor(Math.min(sizeX, sizeY) / 2)
|
||||
if (bCount < 1) bCount = 1
|
||||
}
|
||||
|
||||
const bandDimY = Math.ceil(sizeY / bCount)
|
||||
const bandDimX = Math.ceil(sizeX / bCount)
|
||||
|
||||
// Horizontal bands: sort by max X descending
|
||||
const hSorted = [...curves].sort((a, b) => Math.max(b.x1, b.x2, b.x3) - Math.max(a.x1, a.x2, a.x3))
|
||||
let bandMinY = 0
|
||||
let bandMaxY = bandDimY
|
||||
for (let b = 0; b < bCount; b++) {
|
||||
const bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2)
|
||||
let curveCount = 0
|
||||
for (const c of hSorted) {
|
||||
if (c.y1 === c.y2 && c.y2 === c.y3) continue
|
||||
const curveMinY = Math.min(c.y1, c.y2, c.y3)
|
||||
const curveMaxY = Math.max(c.y1, c.y2, c.y3)
|
||||
if (curveMinY > bandMaxY || curveMaxY < bandMinY) continue
|
||||
const curveOffsetX = c.texelIndex % TEXTURE_WIDTH
|
||||
const curveOffsetY = Math.floor(c.texelIndex / TEXTURE_WIDTH)
|
||||
bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY)
|
||||
curveCount++
|
||||
}
|
||||
bandsTexBandOffsets.push(curveCount, bandTexelOffset)
|
||||
bandMinY += bandDimY
|
||||
bandMaxY += bandDimY
|
||||
}
|
||||
|
||||
// Vertical bands: sort by max Y descending
|
||||
const vSorted = [...curves].sort((a, b) => Math.max(b.y1, b.y2, b.y3) - Math.max(a.y1, a.y2, a.y3))
|
||||
let bandMinX = 0
|
||||
let bandMaxX = bandDimX
|
||||
for (let b = 0; b < bCount; b++) {
|
||||
const bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2)
|
||||
let curveCount = 0
|
||||
for (const c of vSorted) {
|
||||
if (c.x1 === c.x2 && c.x2 === c.x3) continue
|
||||
const curveMinX = Math.min(c.x1, c.x2, c.x3)
|
||||
const curveMaxX = Math.max(c.x1, c.x2, c.x3)
|
||||
if (curveMinX > bandMaxX || curveMaxX < bandMinX) continue
|
||||
const curveOffsetX = c.texelIndex % TEXTURE_WIDTH
|
||||
const curveOffsetY = Math.floor(c.texelIndex / TEXTURE_WIDTH)
|
||||
bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY)
|
||||
curveCount++
|
||||
}
|
||||
bandsTexBandOffsets.push(curveCount, bandTexelOffset)
|
||||
bandMinX += bandDimX
|
||||
bandMaxX += bandDimX
|
||||
}
|
||||
|
||||
codePointsData.push({
|
||||
codePoint: cp,
|
||||
width: Math.floor(gx2 - gx1),
|
||||
height: Math.floor(gy2 - gy1),
|
||||
advanceWidth: Math.floor(glyph.advanceWidth || 0),
|
||||
bearingX: Math.floor(gx1),
|
||||
bearingY: Math.floor(gy1),
|
||||
bandCount: bCount,
|
||||
bandDimX,
|
||||
bandDimY,
|
||||
bandsTexCoordX: bandsTexelIndex % TEXTURE_WIDTH,
|
||||
bandsTexCoordY: Math.floor(bandsTexelIndex / TEXTURE_WIDTH)
|
||||
})
|
||||
}
|
||||
|
||||
// Post-processing: offset curve offsets by band header texel count
|
||||
const bandHeaderTexels = Math.floor(bandsTexBandOffsets.length / 2)
|
||||
for (let i = 1; i < bandsTexBandOffsets.length; i += 2) {
|
||||
bandsTexBandOffsets[i] += bandHeaderTexels
|
||||
}
|
||||
|
||||
return this.buildOutput(codePointsData, curvesTexData, bandsTexBandOffsets, bandsTexCurveOffsets, font)
|
||||
}
|
||||
|
||||
private buildCurves(
|
||||
commands: PathCommand[],
|
||||
gx1: number,
|
||||
gy1: number
|
||||
): Curve[] | null {
|
||||
const curves: Curve[] = []
|
||||
let currentX = 0
|
||||
let currentY = 0
|
||||
let firstCurve = false
|
||||
let startOfShapeX = 0
|
||||
let startOfShapeY = 0
|
||||
|
||||
for (const cmd of commands) {
|
||||
if (cmd.type === 'M') {
|
||||
firstCurve = true
|
||||
currentX = cmd.x - gx1
|
||||
currentY = cmd.y - gy1
|
||||
startOfShapeX = currentX
|
||||
startOfShapeY = currentY
|
||||
} else if (cmd.type === 'L') {
|
||||
const nextX = cmd.x - gx1
|
||||
const nextY = cmd.y - gy1
|
||||
curves.push({
|
||||
first: firstCurve,
|
||||
x1: currentX, y1: currentY,
|
||||
x2: (currentX + nextX) / 2.0,
|
||||
y2: (currentY + nextY) / 2.0,
|
||||
x3: nextX, y3: nextY,
|
||||
texelIndex: 0
|
||||
})
|
||||
firstCurve = false
|
||||
currentX = nextX
|
||||
currentY = nextY
|
||||
} else if (cmd.type === 'Q') {
|
||||
const nextX = cmd.x - gx1
|
||||
const nextY = cmd.y - gy1
|
||||
curves.push({
|
||||
first: firstCurve,
|
||||
x1: currentX, y1: currentY,
|
||||
x2: cmd.x1 - gx1, y2: cmd.y1 - gy1,
|
||||
x3: nextX, y3: nextY,
|
||||
texelIndex: 0
|
||||
})
|
||||
firstCurve = false
|
||||
currentX = nextX
|
||||
currentY = nextY
|
||||
} else if (cmd.type === 'C') {
|
||||
return null
|
||||
} else if (cmd.type === 'Z') {
|
||||
if (currentX !== startOfShapeX || currentY !== startOfShapeY) {
|
||||
curves.push({
|
||||
first: firstCurve,
|
||||
x1: currentX, y1: currentY,
|
||||
x2: (currentX + startOfShapeX) / 2.0,
|
||||
y2: (currentY + startOfShapeY) / 2.0,
|
||||
x3: startOfShapeX, y3: startOfShapeY,
|
||||
texelIndex: 0
|
||||
})
|
||||
firstCurve = false
|
||||
}
|
||||
currentX = startOfShapeX
|
||||
currentY = startOfShapeY
|
||||
}
|
||||
}
|
||||
|
||||
return curves
|
||||
}
|
||||
|
||||
private fixDegenerateCurves(curves: Curve[]): void {
|
||||
for (const c of curves) {
|
||||
if ((c.x2 === c.x1 && c.y2 === c.y1) || (c.x2 === c.x3 && c.y2 === c.y3)) {
|
||||
c.x2 = (c.x1 + c.x3) / 2.0
|
||||
c.y2 = (c.y1 + c.y3) / 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private packCurves(curves: Curve[], curvesTexData: number[]): void {
|
||||
for (const c of curves) {
|
||||
if (c.first && curvesTexData.length % 4 !== 0) {
|
||||
const toAdd = 4 - (curvesTexData.length % 4)
|
||||
for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0)
|
||||
}
|
||||
|
||||
const texelCount = Math.floor(curvesTexData.length / 4)
|
||||
const col = texelCount % TEXTURE_WIDTH
|
||||
const newRow = col === TEXTURE_WIDTH - 1
|
||||
if (newRow) {
|
||||
const toAdd = 8 - (curvesTexData.length % 4)
|
||||
for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0)
|
||||
}
|
||||
|
||||
if (c.first || newRow) {
|
||||
c.texelIndex = Math.floor(curvesTexData.length / 4)
|
||||
curvesTexData.push(c.x1, c.y1)
|
||||
} else {
|
||||
c.texelIndex = Math.floor((Math.floor(curvesTexData.length / 2) - 1) / 2)
|
||||
}
|
||||
|
||||
curvesTexData.push(c.x2, c.y2)
|
||||
curvesTexData.push(c.x3, c.y3)
|
||||
}
|
||||
}
|
||||
|
||||
private buildOutput(
|
||||
codePoints: CodePointData[],
|
||||
curvesList: number[],
|
||||
bandOffsets: number[],
|
||||
curveOffsets: number[],
|
||||
font: Font
|
||||
): SlugData {
|
||||
const map = new Map<number, CodePointData>()
|
||||
codePoints.forEach(cp => map.set(cp.codePoint, cp))
|
||||
|
||||
const curvesTexels = Math.ceil(curvesList.length / 4)
|
||||
const curvesTexHeight = Math.max(1, Math.ceil(curvesTexels / TEXTURE_WIDTH))
|
||||
|
||||
const curvesFloatArray = new Float32Array(TEXTURE_WIDTH * curvesTexHeight * 4)
|
||||
curvesFloatArray.fill(-1.0)
|
||||
curvesFloatArray.set(curvesList)
|
||||
|
||||
const bandsTexels = Math.floor(bandOffsets.length / 2) + Math.floor(curveOffsets.length / 2)
|
||||
const bandsTexHeight = Math.max(1, Math.ceil(bandsTexels / TEXTURE_WIDTH))
|
||||
|
||||
const bandsUintArray = new Uint32Array(TEXTURE_WIDTH * bandsTexHeight * 2)
|
||||
bandsUintArray.set(bandOffsets, 0)
|
||||
bandsUintArray.set(curveOffsets, bandOffsets.length)
|
||||
|
||||
return {
|
||||
codePoints: map,
|
||||
curvesData: curvesFloatArray,
|
||||
bandsData: bandsUintArray,
|
||||
curvesWidth: TEXTURE_WIDTH,
|
||||
curvesHeight: curvesTexHeight,
|
||||
bandsWidth: TEXTURE_WIDTH,
|
||||
bandsHeight: bandsTexHeight,
|
||||
ascender: font.ascender || 0,
|
||||
descender: font.descender || 0,
|
||||
lineGap: (font as unknown as { lineGap?: number }).lineGap || 0,
|
||||
unitsPerEm: font.unitsPerEm || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/engine/text/text-layout.ts
Normal file
177
src/engine/text/text-layout.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { SlugData, CodePointData } from './slug-generator'
|
||||
|
||||
export interface TextOptions {
|
||||
fontScale?: number
|
||||
startX?: number
|
||||
startY?: number
|
||||
lineHeight?: number
|
||||
justify?: 'left' | 'center' | 'right'
|
||||
}
|
||||
|
||||
export interface TextLayoutResult {
|
||||
/** per-instance: (sx, sy, cx, cy) float */
|
||||
scaleBias: Float32Array
|
||||
/** per-instance: (glyphWidth, glyphHeight, bandScaleX, bandScaleY) float */
|
||||
glyphBandScale: Float32Array
|
||||
/** per-instance: (bandMax, bandMax, bandsTexCoordX, bandsTexCoordY) uint32 */
|
||||
bandMaxTexCoords: Uint32Array
|
||||
glyphCount: number
|
||||
}
|
||||
|
||||
export function layoutText(
|
||||
text: string,
|
||||
slugData: SlugData,
|
||||
options: TextOptions = {}
|
||||
): TextLayoutResult {
|
||||
const ascender = slugData.ascender
|
||||
const descender = slugData.descender
|
||||
const lineGap = slugData.lineGap
|
||||
|
||||
const defaultLineHeight = slugData.unitsPerEm > 0
|
||||
? (ascender - descender + lineGap)
|
||||
: 2000
|
||||
|
||||
const {
|
||||
fontScale = 0.5,
|
||||
lineHeight = defaultLineHeight * fontScale,
|
||||
startX = 0,
|
||||
startY = 0,
|
||||
justify = 'left'
|
||||
} = options
|
||||
|
||||
const maxGlyphs = countGlyphs(text)
|
||||
const result: TextLayoutResult = {
|
||||
scaleBias: new Float32Array(maxGlyphs * 4),
|
||||
glyphBandScale: new Float32Array(maxGlyphs * 4),
|
||||
bandMaxTexCoords: new Uint32Array(maxGlyphs * 4),
|
||||
glyphCount: 0
|
||||
}
|
||||
|
||||
const lines = text.split('\n')
|
||||
let currentY = startY
|
||||
|
||||
for (const line of lines) {
|
||||
let lineWidth = 0
|
||||
let j = 0
|
||||
while (j < line.length) {
|
||||
const charCode = line.codePointAt(j)!
|
||||
j += charCode > 0xffff ? 2 : 1
|
||||
const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1)
|
||||
if (data) {
|
||||
lineWidth += data.advanceWidth * fontScale
|
||||
} else if (line[j - 1] === ' ') {
|
||||
lineWidth += 600 * fontScale
|
||||
}
|
||||
}
|
||||
|
||||
let currentX = startX
|
||||
if (justify === 'center') currentX -= lineWidth / 2.0
|
||||
else if (justify === 'right') currentX -= lineWidth
|
||||
|
||||
let k = 0
|
||||
while (k < line.length) {
|
||||
const charCode = line.codePointAt(k)!
|
||||
k += charCode > 0xffff ? 2 : 1
|
||||
const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1)
|
||||
|
||||
if (data) {
|
||||
if (data.width > 0 && data.height > 0) {
|
||||
addGlyph(result, data, currentX, currentY, fontScale)
|
||||
}
|
||||
currentX += data.advanceWidth * fontScale
|
||||
} else if (line[k - 1] === ' ') {
|
||||
currentX += 600 * fontScale
|
||||
}
|
||||
}
|
||||
currentY -= lineHeight
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function measureText(
|
||||
text: string,
|
||||
slugData: SlugData,
|
||||
fontScale: number
|
||||
): number {
|
||||
let width = 0
|
||||
let j = 0
|
||||
while (j < text.length) {
|
||||
const charCode = text.codePointAt(j)!
|
||||
j += charCode > 0xffff ? 2 : 1
|
||||
const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1)
|
||||
if (data) {
|
||||
width += data.advanceWidth * fontScale
|
||||
} else if (text[j - 1] === ' ') {
|
||||
width += 600 * fontScale
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
export function mergeLayouts(layouts: TextLayoutResult[]): TextLayoutResult {
|
||||
const total = layouts.reduce((s, l) => s + l.glyphCount, 0)
|
||||
const merged: TextLayoutResult = {
|
||||
scaleBias: new Float32Array(total * 4),
|
||||
glyphBandScale: new Float32Array(total * 4),
|
||||
bandMaxTexCoords: new Uint32Array(total * 4),
|
||||
glyphCount: 0
|
||||
}
|
||||
for (const layout of layouts) {
|
||||
const n = layout.glyphCount
|
||||
if (n === 0) continue
|
||||
const off = merged.glyphCount
|
||||
merged.scaleBias.set(layout.scaleBias.subarray(0, n * 4), off * 4)
|
||||
merged.glyphBandScale.set(layout.glyphBandScale.subarray(0, n * 4), off * 4)
|
||||
merged.bandMaxTexCoords.set(layout.bandMaxTexCoords.subarray(0, n * 4), off * 4)
|
||||
merged.glyphCount += n
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function countGlyphs(text: string): number {
|
||||
let count = 0
|
||||
let j = 0
|
||||
while (j < text.length) {
|
||||
const charCode = text.codePointAt(j)!
|
||||
j += charCode > 0xffff ? 2 : 1
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function addGlyph(
|
||||
result: TextLayoutResult,
|
||||
cpData: CodePointData,
|
||||
x: number,
|
||||
y: number,
|
||||
fontScale: number
|
||||
): void {
|
||||
const i = result.glyphCount
|
||||
const quadW = cpData.width * fontScale
|
||||
const quadH = cpData.height * fontScale
|
||||
const px = x + cpData.bearingX * fontScale
|
||||
const py = y + cpData.bearingY * fontScale
|
||||
|
||||
const sx = quadW / 2.0
|
||||
const sy = quadH / 2.0
|
||||
const cx = px + sx
|
||||
const cy = py + sy
|
||||
|
||||
result.scaleBias[i * 4 + 0] = sx
|
||||
result.scaleBias[i * 4 + 1] = sy
|
||||
result.scaleBias[i * 4 + 2] = cx
|
||||
result.scaleBias[i * 4 + 3] = cy
|
||||
|
||||
result.glyphBandScale[i * 4 + 0] = cpData.width
|
||||
result.glyphBandScale[i * 4 + 1] = cpData.height
|
||||
result.glyphBandScale[i * 4 + 2] = cpData.width / cpData.bandDimX
|
||||
result.glyphBandScale[i * 4 + 3] = cpData.height / cpData.bandDimY
|
||||
|
||||
result.bandMaxTexCoords[i * 4 + 0] = cpData.bandCount - 1
|
||||
result.bandMaxTexCoords[i * 4 + 1] = cpData.bandCount - 1
|
||||
result.bandMaxTexCoords[i * 4 + 2] = cpData.bandsTexCoordX
|
||||
result.bandMaxTexCoords[i * 4 + 3] = cpData.bandsTexCoordY
|
||||
|
||||
result.glyphCount++
|
||||
}
|
||||
205
src/engine/text/text-renderer.ts
Normal file
205
src/engine/text/text-renderer.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import shaderSource from './text.wgsl?raw'
|
||||
import type { SlugData } from './slug-generator'
|
||||
import type { TextLayoutResult } from './text-layout'
|
||||
|
||||
const TEXT_UNIFORM_FLOATS = 8 // resolution(2) + pad(2) + color(4)
|
||||
const TEXT_UNIFORM_BYTES = TEXT_UNIFORM_FLOATS * 4
|
||||
|
||||
const QUAD_VERTICES = new Float32Array([
|
||||
-1.0, -1.0,
|
||||
-1.0, 1.0,
|
||||
1.0, 1.0,
|
||||
-1.0, -1.0,
|
||||
1.0, 1.0,
|
||||
1.0, -1.0
|
||||
])
|
||||
|
||||
const MAX_GLYPHS = 4096
|
||||
const INSTANCE_STRIDE = 4 * 4
|
||||
|
||||
export class TextRenderer {
|
||||
private device!: GPUDevice
|
||||
private pipeline!: GPURenderPipeline
|
||||
private bindGroup!: GPUBindGroup
|
||||
private uniformBuffer!: GPUBuffer
|
||||
private uniformData = new Float32Array(TEXT_UNIFORM_FLOATS)
|
||||
private quadBuffer!: GPUBuffer
|
||||
|
||||
private scaleBiasBuffer!: GPUBuffer
|
||||
private glyphBandScaleBuffer!: GPUBuffer
|
||||
private bandMaxTexCoordsBuffer!: GPUBuffer
|
||||
|
||||
private curvesTex!: GPUTexture
|
||||
private bandsTex!: GPUTexture
|
||||
private glyphCount = 0
|
||||
private initialized = false
|
||||
|
||||
get ready(): boolean {
|
||||
return this.initialized
|
||||
}
|
||||
|
||||
init(device: GPUDevice, format: GPUTextureFormat, slugData: SlugData): void {
|
||||
this.device = device
|
||||
|
||||
this.createTextures(slugData)
|
||||
|
||||
const module = device.createShaderModule({ code: shaderSource })
|
||||
|
||||
const bindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
||||
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'unfilterable-float' } },
|
||||
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'uint' } }
|
||||
]
|
||||
})
|
||||
|
||||
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] })
|
||||
|
||||
this.pipeline = device.createRenderPipeline({
|
||||
layout: pipelineLayout,
|
||||
vertex: {
|
||||
module,
|
||||
entryPoint: 'vs_main',
|
||||
buffers: [
|
||||
{
|
||||
arrayStride: 2 * 4,
|
||||
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }]
|
||||
},
|
||||
{
|
||||
arrayStride: INSTANCE_STRIDE,
|
||||
stepMode: 'instance',
|
||||
attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x4' }]
|
||||
},
|
||||
{
|
||||
arrayStride: INSTANCE_STRIDE,
|
||||
stepMode: 'instance',
|
||||
attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x4' }]
|
||||
},
|
||||
{
|
||||
arrayStride: INSTANCE_STRIDE,
|
||||
stepMode: 'instance',
|
||||
attributes: [{ shaderLocation: 3, offset: 0, format: 'uint32x4' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
fragment: {
|
||||
module,
|
||||
entryPoint: 'fs_main',
|
||||
targets: [{
|
||||
format,
|
||||
blend: {
|
||||
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
||||
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
|
||||
}
|
||||
}]
|
||||
},
|
||||
primitive: { topology: 'triangle-list' }
|
||||
})
|
||||
|
||||
this.uniformBuffer = device.createBuffer({
|
||||
size: TEXT_UNIFORM_BYTES,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
||||
})
|
||||
|
||||
this.quadBuffer = device.createBuffer({
|
||||
size: QUAD_VERTICES.byteLength,
|
||||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
|
||||
})
|
||||
device.queue.writeBuffer(this.quadBuffer, 0, QUAD_VERTICES)
|
||||
|
||||
this.scaleBiasBuffer = device.createBuffer({
|
||||
size: MAX_GLYPHS * INSTANCE_STRIDE,
|
||||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
|
||||
})
|
||||
this.glyphBandScaleBuffer = device.createBuffer({
|
||||
size: MAX_GLYPHS * INSTANCE_STRIDE,
|
||||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
|
||||
})
|
||||
this.bandMaxTexCoordsBuffer = device.createBuffer({
|
||||
size: MAX_GLYPHS * INSTANCE_STRIDE,
|
||||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
|
||||
})
|
||||
|
||||
this.bindGroup = device.createBindGroup({
|
||||
layout: bindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.uniformBuffer } },
|
||||
{ binding: 1, resource: this.curvesTex.createView() },
|
||||
{ binding: 2, resource: this.bandsTex.createView() }
|
||||
]
|
||||
})
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private createTextures(slugData: SlugData): void {
|
||||
this.curvesTex = this.device.createTexture({
|
||||
size: { width: slugData.curvesWidth, height: slugData.curvesHeight },
|
||||
format: 'rgba32float',
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
|
||||
})
|
||||
this.device.queue.writeTexture(
|
||||
{ texture: this.curvesTex },
|
||||
slugData.curvesData.buffer as BufferSource,
|
||||
{ bytesPerRow: slugData.curvesWidth * 4 * 4 },
|
||||
{ width: slugData.curvesWidth, height: slugData.curvesHeight }
|
||||
)
|
||||
|
||||
this.bandsTex = this.device.createTexture({
|
||||
size: { width: slugData.bandsWidth, height: slugData.bandsHeight },
|
||||
format: 'rg32uint',
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
|
||||
})
|
||||
this.device.queue.writeTexture(
|
||||
{ texture: this.bandsTex },
|
||||
slugData.bandsData.buffer as BufferSource,
|
||||
{ bytesPerRow: slugData.bandsWidth * 2 * 4 },
|
||||
{ width: slugData.bandsWidth, height: slugData.bandsHeight }
|
||||
)
|
||||
}
|
||||
|
||||
updateText(layout: TextLayoutResult): void {
|
||||
if (!this.initialized) return
|
||||
const count = Math.min(layout.glyphCount, MAX_GLYPHS)
|
||||
this.glyphCount = count
|
||||
if (count === 0) return
|
||||
|
||||
const sb = layout.scaleBias.subarray(0, count * 4)
|
||||
this.device.queue.writeBuffer(this.scaleBiasBuffer, 0, sb.buffer as BufferSource, sb.byteOffset, sb.byteLength)
|
||||
const gb = layout.glyphBandScale.subarray(0, count * 4)
|
||||
this.device.queue.writeBuffer(this.glyphBandScaleBuffer, 0, gb.buffer as BufferSource, gb.byteOffset, gb.byteLength)
|
||||
const bm = layout.bandMaxTexCoords.subarray(0, count * 4)
|
||||
this.device.queue.writeBuffer(this.bandMaxTexCoordsBuffer, 0, bm.buffer as BufferSource, bm.byteOffset, bm.byteLength)
|
||||
}
|
||||
|
||||
renderPass(
|
||||
encoder: GPUCommandEncoder,
|
||||
canvasView: GPUTextureView,
|
||||
resolution: Float32Array,
|
||||
textColor: Float32Array
|
||||
): void {
|
||||
if (!this.initialized || this.glyphCount === 0) return
|
||||
|
||||
this.uniformData[0] = resolution[0]
|
||||
this.uniformData[1] = resolution[1]
|
||||
this.uniformData[4] = textColor[0]
|
||||
this.uniformData[5] = textColor[1]
|
||||
this.uniformData[6] = textColor[2]
|
||||
this.uniformData[7] = textColor[3]
|
||||
this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData)
|
||||
|
||||
const pass = encoder.beginRenderPass({
|
||||
colorAttachments: [
|
||||
{ view: canvasView, loadOp: 'load', storeOp: 'store' }
|
||||
]
|
||||
})
|
||||
pass.setPipeline(this.pipeline)
|
||||
pass.setBindGroup(0, this.bindGroup)
|
||||
pass.setVertexBuffer(0, this.quadBuffer)
|
||||
pass.setVertexBuffer(1, this.scaleBiasBuffer)
|
||||
pass.setVertexBuffer(2, this.glyphBandScaleBuffer)
|
||||
pass.setVertexBuffer(3, this.bandMaxTexCoordsBuffer)
|
||||
pass.draw(6, this.glyphCount)
|
||||
pass.end()
|
||||
}
|
||||
}
|
||||
159
src/engine/text/text.wgsl
Normal file
159
src/engine/text/text.wgsl
Normal file
@@ -0,0 +1,159 @@
|
||||
// Text pass: Slug algorithm ported from JSlug (GLSL3 → WGSL).
|
||||
// Evaluates TrueType quadratic Bézier curves directly in the fragment
|
||||
// shader for resolution-independent, anti-aliased text rendering.
|
||||
// No glyph atlas — curves are packed into a float texture, spatially
|
||||
// banded into a uint texture, and ray-traced per fragment.
|
||||
|
||||
const TEXTURE_WIDTH = 4096u;
|
||||
const EPSILON = 0.0001;
|
||||
|
||||
struct TextUniforms {
|
||||
resolution: vec2f,
|
||||
textColor: vec4f,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: TextUniforms;
|
||||
@group(0) @binding(1) var curvesTex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var bandsTex: texture_2d<u32>;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position: vec2f,
|
||||
@location(1) aScaleBias: vec4f,
|
||||
@location(2) aGlyphBandScale: vec4f,
|
||||
@location(3) aBandMaxTexCoords: vec4u,
|
||||
}
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4f,
|
||||
@location(0) vTexCoords: vec2f,
|
||||
@interpolate(flat) @location(1) vGlyphBandScale: vec4f,
|
||||
@interpolate(flat) @location(2) vBandMaxTexCoords: vec4u,
|
||||
@interpolate(flat) @location(3) vPixelsPerEm: vec2f,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_main(in: VsIn) -> VsOut {
|
||||
let px = in.position.x * in.aScaleBias.x + in.aScaleBias.z;
|
||||
let py = in.position.y * in.aScaleBias.y + in.aScaleBias.w;
|
||||
var out: VsOut;
|
||||
out.pos = vec4f(
|
||||
px / u.resolution.x * 2.0 - 1.0,
|
||||
py / u.resolution.y * 2.0 - 1.0,
|
||||
0.0, 1.0
|
||||
);
|
||||
out.vTexCoords = in.position * 0.5 + vec2f(0.5);
|
||||
out.vGlyphBandScale = in.aGlyphBandScale;
|
||||
out.vBandMaxTexCoords = in.aBandMaxTexCoords;
|
||||
let ppe = vec2f(2.0 * in.aScaleBias.x, 2.0 * in.aScaleBias.y);
|
||||
out.vPixelsPerEm = clamp(ppe, vec2f(1.0), vec2f(200.0));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- Slug ray tracer ----------
|
||||
|
||||
fn trace_ray_curve_h(p1: vec2f, p2: vec2f, p3: vec2f, pixels_per_em: f32) -> f32 {
|
||||
if (max(max(p1.x, p2.x), p3.x) * pixels_per_em < -0.5) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let y_flags = select(0u, 2u, p1.y > 0.0)
|
||||
+ select(0u, 4u, p2.y > 0.0)
|
||||
+ select(0u, 8u, p3.y > 0.0);
|
||||
let code = (0x2E74u >> y_flags) & 3u;
|
||||
if (code == 0u) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let a = p1 - p2 * 2.0 + p3;
|
||||
let b = p1 - p2;
|
||||
let c = p1.y;
|
||||
let ayr = 1.0 / a.y;
|
||||
let disc = max(b.y * b.y - a.y * c, 0.0);
|
||||
let d = sqrt(disc);
|
||||
var t1 = (b.y - d) * ayr;
|
||||
var t2 = (b.y + d) * ayr;
|
||||
|
||||
if (abs(a.y) < EPSILON) {
|
||||
t1 = c / (2.0 * b.y);
|
||||
t2 = t1;
|
||||
}
|
||||
|
||||
var coverage = 0.0;
|
||||
|
||||
if ((code & 1u) != 0u) {
|
||||
let x1 = (a.x * t1 - b.x * 2.0) * t1 + p1.x;
|
||||
coverage += clamp(x1 * pixels_per_em + 0.5, 0.0, 1.0);
|
||||
}
|
||||
|
||||
if (code > 1u) {
|
||||
let x2 = (a.x * t2 - b.x * 2.0) * t2 + p1.x;
|
||||
coverage -= clamp(x2 * pixels_per_em + 0.5, 0.0, 1.0);
|
||||
}
|
||||
|
||||
return coverage;
|
||||
}
|
||||
|
||||
fn trace_ray_band_h(
|
||||
band_data: vec2u,
|
||||
pixels_per_em: f32,
|
||||
v_tex: vec2f,
|
||||
glyph_scale: vec2f
|
||||
) -> f32 {
|
||||
var coverage = 0.0;
|
||||
for (var curve = 0u; curve < band_data.x; curve++) {
|
||||
let curve_offset = band_data.y + curve;
|
||||
let curve_loc = textureLoad(bandsTex, vec2u(curve_offset & 0xFFFu, curve_offset >> 12u), 0).xy;
|
||||
let p12 = textureLoad(curvesTex, curve_loc, 0) / vec4f(glyph_scale, glyph_scale) - vec4f(v_tex, v_tex);
|
||||
let p3 = textureLoad(curvesTex, vec2u(curve_loc.x + 1u, curve_loc.y), 0).xy / glyph_scale - v_tex;
|
||||
coverage += trace_ray_curve_h(p12.xy, p12.zw, p3.xy, pixels_per_em);
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
fn trace_ray_band_v(
|
||||
band_data: vec2u,
|
||||
pixels_per_em: f32,
|
||||
v_tex: vec2f,
|
||||
glyph_scale: vec2f
|
||||
) -> f32 {
|
||||
var coverage = 0.0;
|
||||
for (var curve = 0u; curve < band_data.x; curve++) {
|
||||
let curve_offset = band_data.y + curve;
|
||||
let curve_loc = textureLoad(bandsTex, vec2u(curve_offset & 0xFFFu, curve_offset >> 12u), 0).xy;
|
||||
let p12 = textureLoad(curvesTex, curve_loc, 0) / vec4f(glyph_scale, glyph_scale) - vec4f(v_tex, v_tex);
|
||||
let p3 = textureLoad(curvesTex, vec2u(curve_loc.x + 1u, curve_loc.y), 0).xy / glyph_scale - v_tex;
|
||||
coverage += trace_ray_curve_h(p12.yx, p12.wz, p3.yx, pixels_per_em);
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4f {
|
||||
let glyph_scale = in.vGlyphBandScale.xy;
|
||||
let band_scale = in.vGlyphBandScale.zw;
|
||||
let band_max = in.vBandMaxTexCoords.xy;
|
||||
let bands_tex_coords = in.vBandMaxTexCoords.zw;
|
||||
let v_tex = in.vTexCoords;
|
||||
let pixels_per_em = in.vPixelsPerEm;
|
||||
|
||||
let band_index = clamp(
|
||||
vec2u(v_tex * band_scale),
|
||||
vec2u(0u, 0u),
|
||||
band_max
|
||||
);
|
||||
|
||||
let h_band_offset = bands_tex_coords.y * TEXTURE_WIDTH + bands_tex_coords.x + band_index.y;
|
||||
let h_band_data = textureLoad(bandsTex, vec2u(h_band_offset & 0xFFFu, h_band_offset >> 12u), 0).xy;
|
||||
|
||||
let v_band_offset = bands_tex_coords.y * TEXTURE_WIDTH + bands_tex_coords.x + band_max.y + 1u + band_index.x;
|
||||
let v_band_data = textureLoad(bandsTex, vec2u(v_band_offset & 0xFFFu, v_band_offset >> 12u), 0).xy;
|
||||
|
||||
var coverage_x = trace_ray_band_h(h_band_data, pixels_per_em.x, v_tex, glyph_scale);
|
||||
var coverage_y = trace_ray_band_v(v_band_data, pixels_per_em.y, v_tex, glyph_scale);
|
||||
|
||||
coverage_x = min(abs(coverage_x), 1.0);
|
||||
coverage_y = min(abs(coverage_y), 1.0);
|
||||
let slug_alpha = (coverage_x + coverage_y) * 0.5;
|
||||
|
||||
return vec4f(u.textColor.rgb, u.textColor.a * slug_alpha);
|
||||
}
|
||||
16
src/main.ts
Normal file
16
src/main.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { App } from './app'
|
||||
|
||||
const canvas = document.getElementById('gpu') as HTMLCanvasElement
|
||||
const fallback = document.getElementById('fallback') as HTMLElement
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
const app = new App(canvas)
|
||||
const ok = await app.start()
|
||||
if (!ok) {
|
||||
fallback.style.display = 'flex'
|
||||
canvas.style.display = 'none'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
void boot()
|
||||
14
src/ui/input.ts
Normal file
14
src/ui/input.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Window-level pointer position capture. Discrete clicks and hover are
|
||||
// handled by the DOM overlay elements directly; this class only feeds the
|
||||
// continuous pointer position that the background shader consumes.
|
||||
export class Input {
|
||||
x = 0
|
||||
y = 0
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('pointermove', (e) => {
|
||||
this.x = e.clientX
|
||||
this.y = e.clientY
|
||||
})
|
||||
}
|
||||
}
|
||||
254
src/ui/overlay.ts
Normal file
254
src/ui/overlay.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { RESUME, ResumeData, ResumeEntry, SkillGroup } from '../data/resume'
|
||||
import { PROJECTS, CATEGORIES, ProjectPage, ProjectCategory } from '../data/projects/index'
|
||||
|
||||
interface SceneContent {
|
||||
title: string
|
||||
subtitle: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface OverlayCallbacks {
|
||||
onNavClick: (scene: number) => void
|
||||
onButtonClick: () => void
|
||||
}
|
||||
|
||||
// ---------- HTML builders ----------
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function buildContact(data: ResumeData): string {
|
||||
return data.contact
|
||||
.map((c, i) => {
|
||||
const sep = i > 0 ? '<span class="sep">·</span>' : ''
|
||||
if (c.href) return `${sep}<a href="${esc(c.href)}">${esc(c.label)}</a>`
|
||||
return `${sep}<span>${esc(c.label)}</span>`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function buildEntryCard(e: ResumeEntry): string {
|
||||
const bullets = e.bullets.length
|
||||
? `<ul>${e.bullets.map((b) => `<li>${b}</li>`).join('')}</ul>`
|
||||
: ''
|
||||
const thesis = e.thesis ? `<div class="card-thesis">${esc(e.thesis)}</div>` : ''
|
||||
return `<div class="resume-card">
|
||||
<div class="card-date">${esc(e.date)}</div>
|
||||
<div class="card-role">${esc(e.role)}</div>
|
||||
<div class="card-company">${esc(e.company)} — ${esc(e.location)}</div>
|
||||
${thesis}${bullets}
|
||||
</div>`
|
||||
}
|
||||
|
||||
function buildSkillGroup(g: SkillGroup): string {
|
||||
const pills = g.skills.map((s) => `<span class="pill">${esc(s)}</span>`).join('')
|
||||
return `<div class="skills-group">
|
||||
<h4>${esc(g.label)}</h4>
|
||||
<div class="skill-pills">${pills}</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function buildResumeHTML(data: ResumeData): string {
|
||||
const workCards = data.work.map(buildEntryCard).join('')
|
||||
const eduCards = data.education.map(buildEntryCard).join('')
|
||||
const skillGroups = data.skills.map(buildSkillGroup).join('')
|
||||
return `<div class="resume-container">
|
||||
<header class="resume-header">
|
||||
<h2>${esc(data.name)}</h2>
|
||||
<div class="resume-contact">${buildContact(data)}</div>
|
||||
<div class="resume-location">${esc(data.location)}</div>
|
||||
</header>
|
||||
<section class="resume-section">
|
||||
<h3 class="section-title">Summary</h3>
|
||||
<p class="summary-text">${esc(data.summary)}</p>
|
||||
</section>
|
||||
<section class="resume-section">
|
||||
<h3 class="section-title">Work Experience</h3>
|
||||
${workCards}
|
||||
</section>
|
||||
<section class="resume-section">
|
||||
<h3 class="section-title">Education</h3>
|
||||
${eduCards}
|
||||
</section>
|
||||
<section class="resume-section">
|
||||
<h3 class="section-title">Skills</h3>
|
||||
${skillGroups}
|
||||
</section>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// ---------- project HTML builders ----------
|
||||
|
||||
function buildCategoryGridHTML(categories: ProjectCategory[]): string {
|
||||
const cards = categories
|
||||
.map(
|
||||
(c) => `<div class="cat-card" data-cat-id="${esc(c.id)}">
|
||||
<h3 class="cat-label">${esc(c.label)}</h3>
|
||||
<p class="cat-desc">${esc(c.description)}</p>
|
||||
<span class="cat-count">${c.projects.length} project${c.projects.length === 1 ? '' : 's'}</span>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
return `<div class="cat-grid">${cards}</div>`
|
||||
}
|
||||
|
||||
function buildCategoryListHTML(cat: ProjectCategory): string {
|
||||
if (cat.projects.length === 0) {
|
||||
return `<div class="cat-list">
|
||||
<button class="project-back" data-back="grid">← Categories</button>
|
||||
<p class="cat-empty">Nothing here yet.</p>
|
||||
</div>`
|
||||
}
|
||||
const cards = cat.projects
|
||||
.map(
|
||||
(p) => `<div class="project-card" data-project-id="${esc(p.id)}">
|
||||
<div class="project-date">${esc(p.date)}</div>
|
||||
<h3 class="project-name">${esc(p.name)}</h3>
|
||||
<div class="project-tagline">${esc(p.tagline)}</div>
|
||||
<div class="project-tech">${p.tech.map((t) => `<span class="pill">${esc(t)}</span>`).join('')}</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
return `<div class="cat-list">
|
||||
<button class="project-back" data-back="grid">← Categories</button>
|
||||
${cards}
|
||||
</div>`
|
||||
}
|
||||
|
||||
function buildProjectDetailHTML(p: ProjectPage, catId: string): string {
|
||||
return `<div class="project-detail">
|
||||
<button class="project-back" data-back="cat" data-cat-id="${esc(catId)}">← Back</button>
|
||||
<div class="project-date">${esc(p.date)}</div>
|
||||
<h2 class="project-name">${esc(p.name)}</h2>
|
||||
<div class="project-tagline">${esc(p.tagline)}</div>
|
||||
<div class="project-tech">${p.tech.map((t) => `<span class="pill">${esc(t)}</span>`).join('')}</div>
|
||||
<div class="project-body">${p.content}</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// ---------- overlay ----------
|
||||
|
||||
export class DomOverlay {
|
||||
private readonly navItems: HTMLElement[]
|
||||
private readonly titleEl: HTMLElement
|
||||
private readonly subtitleEl: HTMLElement
|
||||
private readonly bodyEl: HTMLElement
|
||||
private readonly sceneContentEl: HTMLElement
|
||||
private readonly projectsSectionEl: HTMLElement
|
||||
private readonly resumeSectionEl: HTMLElement
|
||||
private readonly cycleBtnEl: HTMLElement
|
||||
private readonly scenes: SceneContent[]
|
||||
private projectsBuilt = false
|
||||
private resumeBuilt = false
|
||||
|
||||
constructor(scenes: SceneContent[], callbacks: OverlayCallbacks) {
|
||||
this.scenes = scenes
|
||||
this.navItems = Array.from(document.querySelectorAll<HTMLElement>('[data-nav]'))
|
||||
this.titleEl = document.getElementById('scene-title') as HTMLElement
|
||||
this.subtitleEl = document.getElementById('scene-subtitle') as HTMLElement
|
||||
this.bodyEl = document.getElementById('scene-body') as HTMLElement
|
||||
this.sceneContentEl = document.getElementById('scene-content') as HTMLElement
|
||||
this.projectsSectionEl = document.getElementById('projects-section') as HTMLElement
|
||||
this.resumeSectionEl = document.getElementById('resume-section') as HTMLElement
|
||||
this.cycleBtnEl = document.getElementById('cycle-button') as HTMLElement
|
||||
|
||||
this.navItems.forEach((item, i) => {
|
||||
item.addEventListener('click', () => callbacks.onNavClick(i))
|
||||
})
|
||||
|
||||
this.cycleBtnEl.addEventListener('click', () => callbacks.onButtonClick())
|
||||
|
||||
// delegate clicks inside projects section
|
||||
this.projectsSectionEl.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// category card click → open category
|
||||
const catCard = target.closest<HTMLElement>('.cat-card')
|
||||
if (catCard && catCard.dataset.catId) {
|
||||
this.showCategory(catCard.dataset.catId)
|
||||
return
|
||||
}
|
||||
|
||||
// project card click → open detail
|
||||
const projCard = target.closest<HTMLElement>('.project-card')
|
||||
if (projCard && projCard.dataset.projectId) {
|
||||
// find which category this project belongs to
|
||||
const cat = CATEGORIES.find((c) =>
|
||||
c.projects.some((p) => p.id === projCard.dataset.projectId)
|
||||
)
|
||||
if (cat) {
|
||||
this.showProjectDetail(projCard.dataset.projectId, cat.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// back button
|
||||
const back = target.closest<HTMLElement>('.project-back')
|
||||
if (back && back.dataset.back === 'grid') {
|
||||
this.showCategoryGrid()
|
||||
return
|
||||
}
|
||||
if (back && back.dataset.back === 'cat' && back.dataset.catId) {
|
||||
this.showCategory(back.dataset.catId)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private showCategoryGrid(): void {
|
||||
this.projectsSectionEl.innerHTML = buildCategoryGridHTML(CATEGORIES)
|
||||
}
|
||||
|
||||
private showCategory(id: string): void {
|
||||
const cat = CATEGORIES.find((c) => c.id === id)
|
||||
if (!cat) return
|
||||
this.projectsSectionEl.innerHTML = buildCategoryListHTML(cat)
|
||||
}
|
||||
|
||||
private showProjectDetail(projectId: string, catId: string): void {
|
||||
const p = PROJECTS.find((p) => p.id === projectId)
|
||||
if (!p) return
|
||||
this.projectsSectionEl.innerHTML = buildProjectDetailHTML(p, catId)
|
||||
}
|
||||
|
||||
setScene(scene: number): void {
|
||||
const content = this.scenes[scene]
|
||||
if (content) {
|
||||
this.titleEl.textContent = content.title
|
||||
this.subtitleEl.textContent = content.subtitle
|
||||
this.bodyEl.textContent = content.body
|
||||
}
|
||||
|
||||
// default: show scene-content, hide special sections
|
||||
this.sceneContentEl.classList.remove('hidden')
|
||||
this.projectsSectionEl.classList.remove('visible')
|
||||
this.resumeSectionEl.classList.remove('visible')
|
||||
this.cycleBtnEl.style.display = ''
|
||||
|
||||
if (scene === 2) {
|
||||
this.sceneContentEl.classList.add('hidden')
|
||||
this.projectsSectionEl.classList.add('visible')
|
||||
this.cycleBtnEl.style.display = 'none'
|
||||
if (!this.projectsBuilt) {
|
||||
this.showCategoryGrid()
|
||||
this.projectsBuilt = true
|
||||
} else {
|
||||
// always reset to category grid when landing on projects
|
||||
this.showCategoryGrid()
|
||||
}
|
||||
} else if (scene === 4) {
|
||||
this.sceneContentEl.classList.add('hidden')
|
||||
this.resumeSectionEl.classList.add('visible')
|
||||
this.cycleBtnEl.style.display = 'none'
|
||||
if (!this.resumeBuilt) {
|
||||
this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME)
|
||||
this.resumeBuilt = true
|
||||
}
|
||||
}
|
||||
|
||||
this.navItems.forEach((item, i) => {
|
||||
item.classList.toggle('active', i === scene)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user