Initial commit: WebGPU portfolio — generative-art site with DOM overlay

This commit is contained in:
2026-07-26 19:06:17 +02:00
commit 7172894ebb
30 changed files with 3916 additions and 0 deletions

147
src/app.ts Normal file
View 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)
}
}

View 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

View 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

View 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

View 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: [],
},
]

View 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

View 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
View 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
View 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()])
}
}

View 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);
}

View 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
}
}
}

View 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++
}

View 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
View 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
View 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
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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)
})
}
}