- Hugo static site at root: config, content (home/about/resume/blog + 5 projects), data/resume.yaml, custom layouts (gpu background, resume renderer, projects list), dark theme with canvas behind content - gpu-bg/: extracted WebGPU/WebGL2 spiral engine as a Vite sub-package; slim bootstrap drops router/overlay/text renderer; fixed uniforms (scene0/dim0); self-contained engine.js - archive/: retired Vite SPA preserved (router, overlay, data, engine/text orphaned) - 3-stage Dockerfile (node -> hugo extended/dart-sass -> nginx), static nginx config - DEPLOYMENT.md handover + README rewrite; pre-hugo rollback tag
205 lines
6.8 KiB
TypeScript
205 lines
6.8 KiB
TypeScript
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()
|
|
}
|
|
} |