mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
feat(dashboard): MSDF text engine — the all-WebGPU text linchpin
Day-1 linchpin of the zero-DOM Cognitive OS. Every label, receipt id, and number in the dashboard now renders as glowing in-canvas MSDF glyphs instead of DOM text. Checked-in JetBrains Mono mtsdf atlas (512x512, 95 glyphs) + loader + pure layout helper + FramePass + trap-checked WGSL. Built by a GPT-5.5 swarm against an adversarially-verified spec (5 verifiers + 3 skeptics caught 6 silent-fail traps before a line was written: V-flip, release-binary atlas embed, base-path fetch, linear rgba8unorm, premultiplied blend, ASCII-only charset). Conductor live-GPU audit on the real dashboard found one more at runtime: the aspect transform pushed text off-screen in portrait; fixed to keep glyphs square in both orientations. Renders cyan, crisp, upright, glowing, glyph-by-glyph, landscape + portrait. check 0 errors, build green, golden V-flip test 2/2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f167be2663
commit
389ddfa294
11 changed files with 1314 additions and 112 deletions
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import atlas from '../../../../static/msdf/jetbrains-mono.json';
|
||||
import { layoutText, type MsdfAtlasJson } from '../text/layout';
|
||||
|
||||
describe('MSDF text layout', () => {
|
||||
it('packs the atlas V flip for the top of A loudly', () => {
|
||||
const glyphs = layoutText('A', atlas as MsdfAtlasJson);
|
||||
expect(glyphs).toHaveLength(1);
|
||||
// atlasBounds.top for A is 373.5 in the checked-in 512px atlas.
|
||||
// WebGPU texture V is top-down, so the glyph top must sample 1 - top/512.
|
||||
expect(glyphs[0].v).toBeCloseTo(1 - 373.5 / 512, 4);
|
||||
expect(glyphs[0].v).toBeCloseTo(0.2705, 4);
|
||||
});
|
||||
|
||||
it('falls back to ASCII question mark for non-atlas characters', () => {
|
||||
const [fallback] = layoutText('·', atlas as MsdfAtlasJson);
|
||||
const [question] = layoutText('?', atlas as MsdfAtlasJson);
|
||||
expect(fallback).toEqual(question);
|
||||
});
|
||||
});
|
||||
146
apps/dashboard/src/lib/observatory/text/layout.ts
Normal file
146
apps/dashboard/src/lib/observatory/text/layout.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
export interface MsdfBounds {
|
||||
left: number;
|
||||
bottom: number;
|
||||
right: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export interface MsdfGlyph {
|
||||
unicode: number;
|
||||
advance?: number;
|
||||
planeBounds?: MsdfBounds;
|
||||
atlasBounds?: MsdfBounds;
|
||||
}
|
||||
|
||||
export interface MsdfAtlasJson {
|
||||
atlas: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
metrics?: {
|
||||
lineHeight?: number;
|
||||
};
|
||||
glyphs: MsdfGlyph[];
|
||||
}
|
||||
|
||||
export interface GlyphInstance {
|
||||
/** Quad origin in em/NDC-y units, baseline-relative, +Y up. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** Quad size in em/NDC-y units. */
|
||||
w: number;
|
||||
h: number;
|
||||
/** Atlas UV origin. v is already flipped from atlas yOrigin: bottom to GPU top-down V. */
|
||||
u: number;
|
||||
v: number;
|
||||
/** Atlas UV extent. vh is positive from flipped glyph top to flipped glyph bottom. */
|
||||
uw: number;
|
||||
vh: number;
|
||||
}
|
||||
|
||||
export interface LayoutTextOptions {
|
||||
/** Maximum line width in em units. Overlong lines are truncated with ASCII dots. */
|
||||
maxWidthEm?: number;
|
||||
/** Monospace advance in em units. The checked-in atlas uses 0.6 for every ASCII glyph. */
|
||||
advance?: number;
|
||||
/** Baseline-to-baseline distance in em units. Defaults to the atlas metric, then 1.32. */
|
||||
lineHeight?: number;
|
||||
}
|
||||
|
||||
const ASCII_MIN = 0x20;
|
||||
const ASCII_MAX = 0x7e;
|
||||
const FALLBACK_CODEPOINT = 0x3f; // '?'
|
||||
const DEFAULT_ADVANCE = 0.6;
|
||||
const DEFAULT_LINE_HEIGHT = 1.32;
|
||||
const ELLIPSIS = '...';
|
||||
|
||||
function glyphMapFor(atlas: MsdfAtlasJson): Map<number, MsdfGlyph> {
|
||||
return new Map(atlas.glyphs.map((glyph) => [glyph.unicode, glyph]));
|
||||
}
|
||||
|
||||
function asciiFallback(char: string): string {
|
||||
const codepoint = char.codePointAt(0) ?? FALLBACK_CODEPOINT;
|
||||
if (codepoint >= ASCII_MIN && codepoint <= ASCII_MAX) {
|
||||
return char;
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
function truncateLine(line: string, maxChars: number | undefined): string {
|
||||
if (maxChars === undefined || maxChars < 0) {
|
||||
return line;
|
||||
}
|
||||
if (maxChars === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const chars = Array.from(line, asciiFallback);
|
||||
if (chars.length <= maxChars) {
|
||||
return chars.join('');
|
||||
}
|
||||
if (maxChars <= ELLIPSIS.length) {
|
||||
return '.'.repeat(maxChars);
|
||||
}
|
||||
return `${chars.slice(0, maxChars - ELLIPSIS.length).join('')}${ELLIPSIS}`;
|
||||
}
|
||||
|
||||
function preparedLines(text: string, maxChars: number | undefined): string[] {
|
||||
return text.split('\n').map((line) => truncateLine(line, maxChars));
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout ASCII-only MSDF glyph instances for the checked-in JetBrains Mono atlas.
|
||||
*
|
||||
* Coordinates are baseline-relative with +Y up. Atlas UVs are packed with the required
|
||||
* V flip: atlas yOrigin is bottom, while GPU texture V is top-down. Newlines reset penX
|
||||
* and move the baseline down by lineHeight. Advance is always 0.6em by default, even
|
||||
* for spaces and glyphs without plane bounds.
|
||||
*/
|
||||
export function layoutText(text: string, atlas: MsdfAtlasJson, options: LayoutTextOptions = {}): GlyphInstance[] {
|
||||
const glyphs = glyphMapFor(atlas);
|
||||
const fallback = glyphs.get(FALLBACK_CODEPOINT);
|
||||
const atlasWidth = atlas.atlas.width;
|
||||
const atlasHeight = atlas.atlas.height;
|
||||
const advance = options.advance ?? DEFAULT_ADVANCE;
|
||||
const lineHeight = options.lineHeight ?? atlas.metrics?.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
const maxChars = options.maxWidthEm === undefined ? undefined : Math.max(0, Math.floor(options.maxWidthEm / advance));
|
||||
|
||||
const instances: GlyphInstance[] = [];
|
||||
let penY = 0;
|
||||
|
||||
for (const line of preparedLines(text, maxChars)) {
|
||||
let penX = 0;
|
||||
for (const rawChar of Array.from(line)) {
|
||||
const char = asciiFallback(rawChar);
|
||||
const codepoint = char.codePointAt(0) ?? FALLBACK_CODEPOINT;
|
||||
const glyph = glyphs.get(codepoint) ?? fallback;
|
||||
|
||||
if (glyph?.planeBounds && glyph.atlasBounds) {
|
||||
const plane = glyph.planeBounds;
|
||||
const atlasBounds = glyph.atlasBounds;
|
||||
const u = atlasBounds.left / atlasWidth;
|
||||
const v = 1 - atlasBounds.top / atlasHeight;
|
||||
const uw = (atlasBounds.right - atlasBounds.left) / atlasWidth;
|
||||
const vh = 1 - atlasBounds.bottom / atlasHeight - v;
|
||||
|
||||
instances.push({
|
||||
x: penX + plane.left,
|
||||
y: penY + plane.bottom,
|
||||
w: plane.right - plane.left,
|
||||
h: plane.top - plane.bottom,
|
||||
u,
|
||||
v,
|
||||
uw,
|
||||
vh
|
||||
});
|
||||
}
|
||||
|
||||
penX += advance;
|
||||
}
|
||||
penY -= lineHeight;
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
export const layoutMsdfText = layoutText;
|
||||
80
apps/dashboard/src/lib/observatory/text/msdf-atlas.ts
Normal file
80
apps/dashboard/src/lib/observatory/text/msdf-atlas.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { base } from '$app/paths';
|
||||
import type { MsdfGlyph } from './layout';
|
||||
|
||||
export type MsdfAtlasMetrics = {
|
||||
emSize?: number;
|
||||
lineHeight: number;
|
||||
ascender?: number;
|
||||
descender?: number;
|
||||
underlineY?: number;
|
||||
underlineThickness?: number;
|
||||
};
|
||||
|
||||
export type LoadedMsdfAtlas = {
|
||||
atlas: {
|
||||
type?: string;
|
||||
distanceRange: number;
|
||||
distanceRangeMiddle?: number;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
yOrigin?: string;
|
||||
};
|
||||
metrics: MsdfAtlasMetrics;
|
||||
glyphs: MsdfGlyph[];
|
||||
glyphMap: Map<number, MsdfGlyph>;
|
||||
texture: GPUTexture;
|
||||
textureView: GPUTextureView;
|
||||
sampler: GPUSampler;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
type RawAtlas = Omit<LoadedMsdfAtlas, 'glyphMap' | 'texture' | 'textureView' | 'sampler' | 'dispose'>;
|
||||
|
||||
export async function loadMsdfAtlas(device: GPUDevice): Promise<LoadedMsdfAtlas> {
|
||||
const jsonUrl = `${base}/msdf/jetbrains-mono.json`;
|
||||
const pngUrl = `${base}/msdf/jetbrains-mono.png`;
|
||||
|
||||
const jsonResponse = await fetch(jsonUrl);
|
||||
if (!jsonResponse.ok) throw new Error(`MSDF atlas JSON failed: ${jsonResponse.status} ${jsonUrl}`);
|
||||
const raw = (await jsonResponse.json()) as RawAtlas;
|
||||
if (raw.atlas?.yOrigin !== 'bottom') {
|
||||
throw new Error(`MSDF atlas yOrigin must be bottom, got ${raw.atlas?.yOrigin ?? 'missing'}`);
|
||||
}
|
||||
|
||||
const pngResponse = await fetch(pngUrl);
|
||||
if (!pngResponse.ok) throw new Error(`MSDF atlas PNG failed: ${pngResponse.status} ${pngUrl}`);
|
||||
const blob = await pngResponse.blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const texture = device.createTexture({
|
||||
label: 'msdf-jetbrains-mono-rgba8unorm',
|
||||
size: [bitmap.width, bitmap.height, 1],
|
||||
format: 'rgba8unorm',
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
|
||||
});
|
||||
device.queue.copyExternalImageToTexture(
|
||||
{ source: bitmap },
|
||||
{ texture },
|
||||
{ width: bitmap.width, height: bitmap.height }
|
||||
);
|
||||
bitmap.close?.();
|
||||
|
||||
const sampler = device.createSampler({
|
||||
label: 'msdf-jetbrains-mono-linear-sampler',
|
||||
magFilter: 'linear',
|
||||
minFilter: 'linear',
|
||||
mipmapFilter: 'linear',
|
||||
addressModeU: 'clamp-to-edge',
|
||||
addressModeV: 'clamp-to-edge'
|
||||
});
|
||||
const textureView = texture.createView({ label: 'msdf-jetbrains-mono-view' });
|
||||
const glyphMap = new Map(raw.glyphs.map((glyph) => [glyph.unicode, glyph]));
|
||||
return {
|
||||
...raw,
|
||||
glyphMap,
|
||||
texture,
|
||||
textureView,
|
||||
sampler,
|
||||
dispose: () => texture.destroy()
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
export const MSDF_TEXT_WGSL = /* wgsl */ `
|
||||
struct Params {
|
||||
frame: f32,
|
||||
loop_phase: f32,
|
||||
node_count: f32,
|
||||
edge_count: f32,
|
||||
path_count: f32,
|
||||
pulse: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
struct Glyph {
|
||||
anchor_size: vec4f,
|
||||
quad_offset: vec4f,
|
||||
uv_rect: vec4f,
|
||||
info: vec4f,
|
||||
color: vec4f,
|
||||
};
|
||||
|
||||
struct VSOut {
|
||||
@builtin(position) clip: vec4f,
|
||||
@location(0) uv: vec2f,
|
||||
@location(1) @interpolate(flat) info: vec4f,
|
||||
@location(2) @interpolate(flat) color: vec4f,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var<storage, read> glyphs: array<Glyph>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
@group(0) @binding(3) var atlas_tex: texture_2d<f32>;
|
||||
|
||||
const QUAD = array<vec2f, 6>(
|
||||
vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0),
|
||||
vec2f(0.0, 0.0), vec2f(1.0, 1.0), vec2f(0.0, 1.0)
|
||||
);
|
||||
|
||||
fn median3(c: vec3f) -> f32 {
|
||||
return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_text(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
|
||||
let glyph = glyphs[ii];
|
||||
let corner = QUAD[vi];
|
||||
let anchor = glyph.anchor_size.xy;
|
||||
let size = glyph.anchor_size.zw;
|
||||
let quad_offset = glyph.quad_offset.xy;
|
||||
let uv_min = glyph.uv_rect.xy;
|
||||
let uv_max = glyph.uv_rect.zw;
|
||||
let aspect = max(0.0001, params.viewport_w / max(1.0, params.viewport_h));
|
||||
var pos = anchor + quad_offset + corner * size;
|
||||
// Keep glyphs square in BOTH orientations: normalize by the longer axis.
|
||||
// Landscape (aspect>1): narrow x. Portrait (aspect<1): shrink y instead —
|
||||
// dividing x by aspect<1 would WIDEN x and push text off-screen.
|
||||
pos.x = pos.x / max(aspect, 1.0);
|
||||
pos.y = pos.y * min(aspect, 1.0);
|
||||
var out: VSOut;
|
||||
out.clip = vec4f(pos, 0.0, 1.0);
|
||||
out.uv = vec2f(mix(uv_min.x, uv_max.x, corner.x), mix(uv_max.y, uv_min.y, corner.y));
|
||||
out.info = glyph.info;
|
||||
out.color = glyph.color;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_text(in: VSOut) -> @location(0) vec4f {
|
||||
let atlas_px = vec2f(textureDimensions(atlas_tex, 0));
|
||||
let msdf = textureSample(atlas_tex, atlas_sampler, in.uv).rgb;
|
||||
let dist = median3(msdf);
|
||||
let uv_width = max(fwidth(in.uv), vec2f(1.0 / max(atlas_px.x, 1.0), 1.0 / max(atlas_px.y, 1.0)));
|
||||
let texels_per_px = max(length(uv_width * atlas_px), 0.0001);
|
||||
let screen_range = max(0.5, 4.0 / texels_per_px);
|
||||
let px_dist = screen_range * (dist - 0.5);
|
||||
let coverage = clamp(px_dist + 0.5, 0.0, 1.0);
|
||||
let reveal_span = max(1.0, in.info.y);
|
||||
let reveal = clamp((params.frame - in.info.x) / reveal_span, 0.0, 1.0);
|
||||
let alpha = coverage * in.color.a * reveal;
|
||||
if (alpha < 0.001) { discard; }
|
||||
let rgb = in.color.rgb * params.brightness;
|
||||
return vec4f(rgb * alpha, alpha);
|
||||
}
|
||||
`;
|
||||
243
apps/dashboard/src/lib/observatory/text/text-layer.ts
Normal file
243
apps/dashboard/src/lib/observatory/text/text-layer.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import type { ObservatoryEngine, FramePass } from '$lib/observatory/engine';
|
||||
import { rgb01 } from '$lib/observatory/cognitive-palette';
|
||||
import { layoutText, type GlyphInstance } from './layout';
|
||||
import { loadMsdfAtlas, type LoadedMsdfAtlas } from './msdf-atlas';
|
||||
import { MSDF_TEXT_WGSL } from './shaders/msdf-text.wgsl';
|
||||
|
||||
type RoutePick = { id: string; kind: string; index?: number; payload?: unknown };
|
||||
|
||||
export type TextLayerItem = {
|
||||
id?: string;
|
||||
kind?: string;
|
||||
text: string;
|
||||
/** Logical NDC anchor, before shader's aspect divide. y is baseline, +Y up. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** NDC-Y units per em. */
|
||||
size?: number;
|
||||
color?: [number, number, number, number];
|
||||
startFrame?: number;
|
||||
revealSpan?: number;
|
||||
maxWidthEm?: number;
|
||||
maxLines?: number;
|
||||
};
|
||||
|
||||
type TextRunRect = {
|
||||
id: string;
|
||||
kind: string;
|
||||
text: string;
|
||||
x0: number;
|
||||
x1: number;
|
||||
y0: number;
|
||||
y1: number;
|
||||
payload: TextLayerItem;
|
||||
};
|
||||
|
||||
const GLYPH_FLOATS = 20;
|
||||
const DEFAULT_COLOR: [number, number, number, number] = [...rgb01('#22C7DE'), 1];
|
||||
|
||||
export class TextLayerPass implements FramePass {
|
||||
private engine: ObservatoryEngine;
|
||||
private atlas: LoadedMsdfAtlas | null = null;
|
||||
private bindLayout: GPUBindGroupLayout | null = null;
|
||||
private pipeline: GPURenderPipeline | null = null;
|
||||
private glyphBuffer: GPUBuffer | null = null;
|
||||
private bindGroup: GPUBindGroup | null = null;
|
||||
private glyphCapacity = 0;
|
||||
private glyphCount = 0;
|
||||
private pendingItems: TextLayerItem[] = [];
|
||||
private runs: TextRunRect[] = [];
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(engine: ObservatoryEngine) {
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this.initInner();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async initInner(): Promise<void> {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.engine.paramsBuffer) return;
|
||||
this.atlas = await loadMsdfAtlas(device);
|
||||
this.ensurePipeline(device);
|
||||
if (this.pendingItems.length) this.uploadItems(this.pendingItems);
|
||||
}
|
||||
|
||||
setText(items: string | TextLayerItem | TextLayerItem[]): void {
|
||||
const normalized = typeof items === 'string' ? [{ text: items, x: -0.62, y: 0, size: 0.075 }] : Array.isArray(items) ? items : [items];
|
||||
this.pendingItems = normalized;
|
||||
this.uploadItems(normalized);
|
||||
}
|
||||
|
||||
private ensurePipeline(device: GPUDevice): void {
|
||||
if (this.pipeline || !this.engine.paramsBuffer) return;
|
||||
const module = device.createShaderModule({ label: 'msdf-text-wgsl', code: MSDF_TEXT_WGSL });
|
||||
this.bindLayout = device.createBindGroupLayout({
|
||||
label: 'msdf-text-bind-layout',
|
||||
entries: [
|
||||
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
|
||||
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
|
||||
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
|
||||
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }
|
||||
]
|
||||
});
|
||||
const layout = device.createPipelineLayout({ label: 'msdf-text-pipeline-layout', bindGroupLayouts: [this.bindLayout] });
|
||||
const overBlend: GPUBlendState = {
|
||||
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
||||
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
|
||||
};
|
||||
this.pipeline = device.createRenderPipeline({
|
||||
label: 'msdf-text-pipeline',
|
||||
layout,
|
||||
vertex: { module, entryPoint: 'vs_text' },
|
||||
fragment: { module, entryPoint: 'fs_text', targets: [{ format: this.engine.sceneFormat, blend: overBlend }] },
|
||||
primitive: { topology: 'triangle-list' }
|
||||
});
|
||||
}
|
||||
|
||||
private uploadItems(items: TextLayerItem[]): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.engine.paramsBuffer || !this.atlas) return;
|
||||
this.ensurePipeline(device);
|
||||
const packed: number[] = [];
|
||||
const runs: TextRunRect[] = [];
|
||||
let glyphIndex = 0;
|
||||
|
||||
items.forEach((item, itemIndex) => {
|
||||
const size = item.size ?? 0.075;
|
||||
const laid = layoutText(item.text, this.atlas!, {
|
||||
maxWidthEm: item.maxWidthEm
|
||||
});
|
||||
const color = item.color ?? DEFAULT_COLOR;
|
||||
const runGlyphs = laid;
|
||||
let x0 = Number.POSITIVE_INFINITY;
|
||||
let x1 = Number.NEGATIVE_INFINITY;
|
||||
let y0 = Number.POSITIVE_INFINITY;
|
||||
let y1 = Number.NEGATIVE_INFINITY;
|
||||
for (const glyph of runGlyphs) {
|
||||
const gx0 = item.x + glyph.x * size;
|
||||
const gx1 = item.x + (glyph.x + glyph.w) * size;
|
||||
const gy0 = item.y + glyph.y * size;
|
||||
const gy1 = item.y + (glyph.y + glyph.h) * size;
|
||||
x0 = Math.min(x0, gx0);
|
||||
x1 = Math.max(x1, gx1);
|
||||
y0 = Math.min(y0, gy0);
|
||||
y1 = Math.max(y1, gy1);
|
||||
packGlyph(packed, item, glyph, color, size, glyphIndex++);
|
||||
}
|
||||
if (runGlyphs.length > 0) {
|
||||
runs.push({
|
||||
id: item.id ?? `msdf-text:${itemIndex}`,
|
||||
kind: item.kind ?? 'text',
|
||||
text: item.text,
|
||||
x0,
|
||||
x1,
|
||||
y0,
|
||||
y1,
|
||||
payload: item
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.runs = runs;
|
||||
this.glyphCount = packed.length / GLYPH_FLOATS;
|
||||
const floats = new Float32Array(packed.length || GLYPH_FLOATS);
|
||||
floats.set(packed);
|
||||
this.ensureGlyphBuffer(device, Math.max(1, this.glyphCount));
|
||||
if (!this.glyphBuffer || !this.bindLayout) return;
|
||||
device.queue.writeBuffer(this.glyphBuffer, 0, floats);
|
||||
this.bindGroup = device.createBindGroup({
|
||||
label: 'msdf-text-bind-group',
|
||||
layout: this.bindLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
|
||||
{ binding: 1, resource: { buffer: this.glyphBuffer } },
|
||||
{ binding: 2, resource: this.atlas.sampler },
|
||||
{ binding: 3, resource: this.atlas.textureView }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private ensureGlyphBuffer(device: GPUDevice, glyphCount: number): void {
|
||||
if (this.glyphBuffer && this.glyphCapacity >= glyphCount) return;
|
||||
this.glyphBuffer?.destroy();
|
||||
this.glyphCapacity = Math.max(glyphCount, Math.ceil(this.glyphCapacity * 1.5), 32);
|
||||
this.glyphBuffer = device.createBuffer({
|
||||
label: 'msdf-text-glyphs',
|
||||
size: this.glyphCapacity * GLYPH_FLOATS * 4,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
}
|
||||
|
||||
render(pass: GPURenderPassEncoder): void {
|
||||
if (!this.pipeline || !this.bindGroup || this.glyphCount <= 0) return;
|
||||
pass.setPipeline(this.pipeline);
|
||||
pass.setBindGroup(0, this.bindGroup);
|
||||
pass.draw(6, this.glyphCount);
|
||||
}
|
||||
|
||||
pickAt(ndcX: number, ndcY: number): RoutePick | null {
|
||||
const aspect = Math.max(0.0001, (this.engine.params[6] || 1) / Math.max(1, this.engine.params[7] || 1));
|
||||
// Mirror the shader's square-in-both-orientations transform (msdf-text.wgsl):
|
||||
// x /= max(aspect,1); y *= min(aspect,1).
|
||||
const xScale = Math.max(aspect, 1);
|
||||
const yScale = Math.min(aspect, 1);
|
||||
for (const run of this.runs) {
|
||||
const x0 = run.x0 / xScale;
|
||||
const x1 = run.x1 / xScale;
|
||||
const y0 = run.y0 * yScale;
|
||||
const y1 = run.y1 * yScale;
|
||||
if (ndcX >= x0 && ndcX <= x1 && ndcY >= y0 && ndcY <= y1) {
|
||||
return { id: run.id, kind: run.kind, payload: run.payload };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.glyphBuffer?.destroy();
|
||||
this.glyphBuffer = null;
|
||||
this.atlas?.dispose();
|
||||
this.atlas = null;
|
||||
this.bindGroup = null;
|
||||
this.pipeline = null;
|
||||
}
|
||||
}
|
||||
|
||||
function packGlyph(
|
||||
out: number[],
|
||||
item: TextLayerItem,
|
||||
glyph: GlyphInstance,
|
||||
color: [number, number, number, number],
|
||||
size: number,
|
||||
glyphIndex: number
|
||||
): void {
|
||||
const ageFrame = (item.startFrame ?? 0) + glyphIndex * 2;
|
||||
const revealSpan = item.revealSpan ?? 18;
|
||||
out.push(
|
||||
item.x,
|
||||
item.y,
|
||||
glyph.w * size,
|
||||
glyph.h * size,
|
||||
glyph.x * size,
|
||||
glyph.y * size,
|
||||
0,
|
||||
0,
|
||||
glyph.u,
|
||||
glyph.v,
|
||||
glyph.u + glyph.uw,
|
||||
glyph.v + glyph.vh,
|
||||
ageFrame,
|
||||
revealSpan,
|
||||
0,
|
||||
0,
|
||||
color[0],
|
||||
color[1],
|
||||
color[2],
|
||||
color[3]
|
||||
);
|
||||
}
|
||||
50
apps/dashboard/src/routes/(app)/_msdftest/+page.svelte
Normal file
50
apps/dashboard/src/routes/(app)/_msdftest/+page.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import ObservatoryCanvas from '$lib/components/ObservatoryCanvas.svelte';
|
||||
import type { ObservatoryEngine } from '$lib/observatory/engine';
|
||||
import { TextLayerPass } from '$lib/observatory/text/text-layer';
|
||||
|
||||
let hostEl: HTMLDivElement | null = $state(null);
|
||||
let textPass: TextLayerPass | null = null;
|
||||
|
||||
async function handleReady(engine: ObservatoryEngine) {
|
||||
const pass = new TextLayerPass(engine);
|
||||
textPass = pass;
|
||||
await pass.init();
|
||||
pass.setText({
|
||||
id: 'msdf-test-line',
|
||||
kind: 'msdf-test',
|
||||
text: 'hello | 5de3e41f | trust 51%',
|
||||
x: -0.62,
|
||||
y: 0.03,
|
||||
size: 0.105,
|
||||
color: [0.14, 0.78, 0.87, 1],
|
||||
startFrame: 0,
|
||||
revealSpan: 20,
|
||||
maxWidthEm: 18
|
||||
});
|
||||
engine.addPass(pass);
|
||||
engine.demoClock.reset();
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (!hostEl || !textPass) return;
|
||||
const rect = hostEl.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const ndcY = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
|
||||
const hit = textPass.pickAt(ndcX, ndcY);
|
||||
if (hit) console.info('[msdf-test] picked', hit.id);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
textPass?.dispose();
|
||||
textPass = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Transparent pointer host only; ObservatoryCanvas owns the single visible canvas. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div bind:this={hostEl} class="fixed inset-0 bg-[#020307]" onpointerdown={handlePointerDown}>
|
||||
<ObservatoryCanvas demo="recall-path" seed="msdf-test-v1" onready={handleReady} />
|
||||
</div>
|
||||
1
apps/dashboard/static/msdf/jetbrains-mono.json
Normal file
1
apps/dashboard/static/msdf/jetbrains-mono.json
Normal file
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/static/msdf/jetbrains-mono.png
Normal file
BIN
apps/dashboard/static/msdf/jetbrains-mono.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Loading…
Add table
Add a link
Reference in a new issue