- The C settlement kernel graded every receipt measured, from hardware, by
hardware_meter, whatever its meter; it now writes the grade and device the
meter states. Its host harness's figures are constants: projected, from a
simulation, by model_estimated (oer-receipts §2: an Estimator figure is
projected). The Zephyr skeleton states projected and no longer seals a
compute figure of 0. firmware_vectors.json is regenerated; both verifiers
pass it.
- wai-web: the world demo seals the page's estimate labelled Estimator and says
so on screen (label_page_figure, tested); the quantum demos seal the unmetered
marker instead of invented constants, and their getters say a 0 is unmetered.
- The quantum studio shows a figure with its class, coverage and uncertainty;
the quantum CLI prints the unmetered marker as unmetered (tested); the
metering tools head a bracket as a bracket and print the marker on stdout when
they seal it; a provenance total names the class it reads at (tested).
- energy-measurement §2.8 and §6 record which demos still seal an illustrative
figure the page passes in.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 2a8d45aa0d)
925 lines
40 KiB
JavaScript
925 lines
40 KiB
JavaScript
// WAI interactive-worlds browser sink — WebGPU renderer over the
|
||
// WorldSink WASM binding (the same `wai` engine native sinks run).
|
||
//
|
||
// Two modes, decided by the payload itself:
|
||
// - sealed replay (`wai.world.replay`): play the op log, then verify
|
||
// the BLAKE3 state hash against the publisher's Ed25519 seal.
|
||
// - live (`wai.world.live`): this tab becomes a signed ACTOR. Clicks
|
||
// author real CausalOps (Ed25519-signed in WASM); a BroadcastChannel
|
||
// carries them to other tabs — transport is out of the extension's
|
||
// scope, and this is the smallest honest one. N tabs converge on the
|
||
// identical state hash because the math does, not because a server
|
||
// says so.
|
||
|
||
import init, { WorldSink, Actor, op_lamport } from './pkg/wai_web.js';
|
||
|
||
const WORLD = { xMin: -9, xMax: 9, yMin: -1, yMax: 11 }; // ortho camera box
|
||
const TICK_HZ = 60;
|
||
const SHOVE = 18.0; // base impulse; scaled by 1/distance — point-blank clicks snap a 20%-yield vertical, distant clicks wobble
|
||
const OP_LEAD_TICKS = 8; // author ops slightly in the future
|
||
const HASH_EVERY = 60; // live-mode hash exchange cadence (ticks)
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const ui = {
|
||
vector: $('vector'), restart: $('restart'), pause: $('pause'), seal: $('seal'), badge: $('badge'),
|
||
sim: $('sim'), tick: $('tick'), final: $('final'), nodes: $('nodes'),
|
||
hash: $('hash'), bytes: $('bytes'), actors: $('actors'), sync: $('sync'), receipt: $('receipt'),
|
||
assets: $('assets'),
|
||
};
|
||
|
||
// Decoded wai.mesh.glb asset (gallery scene): {positions, edges,
|
||
// triIndices} — presentation only, never part of the state hash.
|
||
let assetMesh = null;
|
||
// 'sim' (2D), 'mesh' (3D PBR), or 'splat' (3D Gaussian splats) — set per vector.
|
||
let renderMode = 'sim';
|
||
|
||
const toHex = (u8) => Array.from(u8, b => b.toString(16).padStart(2, '0')).join('');
|
||
const fromHex = (s) => new Uint8Array(s.match(/.{2}/g).map(h => parseInt(h, 16)));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// WebGPU setup
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const canvas = $('c');
|
||
if (!navigator.gpu) {
|
||
document.body.insertAdjacentHTML('beforeend',
|
||
'<p style="color:#ff6b6b">WebGPU unavailable — this sink declares capability by the core dispatch rule: fall back to the recorded rendition.</p>');
|
||
throw new Error('no webgpu');
|
||
}
|
||
const adapter = await navigator.gpu.requestAdapter();
|
||
const device = await adapter.requestDevice();
|
||
const ctx = canvas.getContext('webgpu');
|
||
const format = navigator.gpu.getPreferredCanvasFormat();
|
||
ctx.configure({ device, format, alphaMode: 'premultiplied' });
|
||
|
||
const sx = 2 / (WORLD.xMax - WORLD.xMin), sy = 2 / (WORLD.yMax - WORLD.yMin);
|
||
const ox = -(WORLD.xMax + WORLD.xMin) / 2, oy = -(WORLD.yMax + WORLD.yMin) / 2;
|
||
const uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||
device.queue.writeBuffer(uniform, 0, new Float32Array([sx, sy, ox, oy]));
|
||
|
||
const shader = device.createShaderModule({ code: /* wgsl */`
|
||
struct Cam { scale: vec2f, offset: vec2f }
|
||
@group(0) @binding(0) var<uniform> cam: Cam;
|
||
fn toClip(p: vec2f) -> vec2f { return (p + cam.offset) * cam.scale; }
|
||
|
||
struct CircOut {
|
||
@builtin(position) pos: vec4f,
|
||
@location(0) uv: vec2f,
|
||
@location(1) kind: f32,
|
||
}
|
||
@vertex
|
||
fn circVs(@builtin(vertex_index) vi: u32,
|
||
@location(0) center: vec3f,
|
||
@location(1) r: f32,
|
||
@location(2) kind: f32) -> CircOut {
|
||
var corners = array<vec2f, 6>(
|
||
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
|
||
vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0));
|
||
let c = corners[vi];
|
||
var out: CircOut;
|
||
out.pos = vec4f(toClip(center.xy + c * r), 0.0, 1.0);
|
||
out.uv = c;
|
||
out.kind = kind;
|
||
return out;
|
||
}
|
||
@fragment
|
||
fn circFs(in: CircOut) -> @location(0) vec4f {
|
||
let d = length(in.uv);
|
||
if (d > 1.0) { discard; }
|
||
let glow = smoothstep(1.0, 0.55, d);
|
||
let free = vec3f(0.0, 0.898, 0.627);
|
||
let anch = vec3f(0.941, 0.706, 0.161);
|
||
let col = mix(free, anch, in.kind);
|
||
return vec4f(col * glow, glow);
|
||
}
|
||
|
||
@vertex
|
||
fn lineVs(@location(0) p: vec3f) -> @builtin(position) vec4f {
|
||
return vec4f(toClip(p.xy), 0.0, 1.0);
|
||
}
|
||
@fragment
|
||
fn lineFs() -> @location(0) vec4f { return vec4f(0.0, 0.72, 0.83, 0.9); }
|
||
|
||
@vertex
|
||
fn groundVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
||
let x = select(-9.0, 9.0, vi == 1u);
|
||
return vec4f(toClip(vec2f(x, 0.0)), 0.0, 1.0);
|
||
}
|
||
@fragment
|
||
fn groundFs() -> @location(0) vec4f { return vec4f(0.18, 0.23, 0.32, 1.0); }
|
||
`});
|
||
|
||
const circPipeline = device.createRenderPipeline({
|
||
layout: 'auto',
|
||
vertex: {
|
||
module: shader, entryPoint: 'circVs',
|
||
buffers: [{
|
||
arrayStride: 20, stepMode: 'instance',
|
||
attributes: [
|
||
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
|
||
{ shaderLocation: 1, offset: 12, format: 'float32' },
|
||
{ shaderLocation: 2, offset: 16, format: 'float32' },
|
||
],
|
||
}],
|
||
},
|
||
fragment: { module: shader, entryPoint: 'circFs', targets: [{
|
||
format,
|
||
blend: { color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
|
||
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' } },
|
||
}] },
|
||
primitive: { topology: 'triangle-list' },
|
||
});
|
||
const linePipeline = device.createRenderPipeline({
|
||
layout: 'auto',
|
||
vertex: {
|
||
module: shader, entryPoint: 'lineVs',
|
||
buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }],
|
||
},
|
||
fragment: { module: shader, entryPoint: 'lineFs', targets: [{ format }] },
|
||
primitive: { topology: 'line-list' },
|
||
});
|
||
const groundPipeline = device.createRenderPipeline({
|
||
layout: 'auto',
|
||
vertex: { module: shader, entryPoint: 'groundVs' },
|
||
fragment: { module: shader, entryPoint: 'groundFs', targets: [{ format }] },
|
||
primitive: { topology: 'line-list' },
|
||
});
|
||
|
||
const circBind = device.createBindGroup({ layout: circPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniform } }] });
|
||
const lineBind = device.createBindGroup({ layout: linePipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniform } }] });
|
||
const groundBind = device.createBindGroup({ layout: groundPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniform } }] });
|
||
|
||
function gpuBuf() { return { buf: null, cap: 0 }; }
|
||
const circles = gpuBuf(), lines = gpuBuf();
|
||
function upload(slot, data) {
|
||
const bytes = data.byteLength;
|
||
if (bytes > slot.cap) {
|
||
slot.buf?.destroy();
|
||
slot.cap = Math.max(256, bytes * 2);
|
||
slot.buf = device.createBuffer({ size: slot.cap, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
||
}
|
||
if (bytes > 0) device.queue.writeBuffer(slot.buf, 0, data);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3D render path (gallery asset rendering: PBR mesh + Gaussian splats)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Minimal column-major mat4 helpers (no dependency).
|
||
const mat4 = {
|
||
perspective(fovy, aspect, near, far) {
|
||
const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far);
|
||
return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0];
|
||
},
|
||
mul(a, b) {
|
||
const o = new Array(16);
|
||
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
|
||
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
|
||
}
|
||
return o;
|
||
},
|
||
lookAt(eye, ctr, up) {
|
||
const z = norm3(sub3(eye, ctr)), x = norm3(cross3(up, z)), y = cross3(z, x);
|
||
return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0,
|
||
-dot3(x, eye), -dot3(y, eye), -dot3(z, eye), 1];
|
||
},
|
||
};
|
||
const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||
const cross3 = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||
const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||
const norm3 = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
|
||
|
||
// Orbiting camera for an object at the origin. Returns vp + view + proj
|
||
// separately: the splat renderer needs view & proj apart to build the
|
||
// EWA covariance-projection Jacobian.
|
||
const FOVY = 0.9;
|
||
function camera(angle) {
|
||
const r = 3.2, eye = [Math.sin(angle) * r, 1.1, Math.cos(angle) * r];
|
||
const proj = mat4.perspective(FOVY, canvas.width / canvas.height, 0.1, 100);
|
||
const view = mat4.lookAt(eye, [0, 0, 0], [0, 1, 0]);
|
||
return { vp: mat4.mul(proj, view), view, proj, eye };
|
||
}
|
||
// Cam uniform (256 B): vp[16] view[16] proj[16] eye(vec4) params(vec4:
|
||
// vpW, vpH, focalX, focalY).
|
||
const cam3d = device.createBuffer({ size: 256, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||
const CAM_WGSL = `struct Cam {
|
||
vp: mat4x4f, view: mat4x4f, proj: mat4x4f, eye: vec4f, params: vec4f,
|
||
}
|
||
@group(0) @binding(0) var<uniform> cam: Cam;`;
|
||
|
||
let depthTex = device.createTexture({
|
||
size: [canvas.width, canvas.height], format: 'depth24plus',
|
||
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
||
});
|
||
|
||
// ---- PBR-ish mesh: solid triangles, flat normals from derivatives ----
|
||
const meshShader = device.createShaderModule({ code: /* wgsl */`
|
||
${CAM_WGSL}
|
||
struct VOut { @builtin(position) clip: vec4f, @location(0) world: vec3f }
|
||
@vertex fn vs(@location(0) p: vec3f) -> VOut {
|
||
var o: VOut; o.world = p; o.clip = cam.vp * vec4f(p, 1.0); return o;
|
||
}
|
||
@fragment fn fs(in: VOut) -> @location(0) vec4f {
|
||
let n = normalize(cross(dpdx(in.world), dpdy(in.world))); // flat normal, no attribute needed
|
||
let L = normalize(vec3f(0.5, 0.9, 0.4));
|
||
let V = normalize(cam.eye.xyz - in.world);
|
||
let H = normalize(L + V);
|
||
let base = vec3f(0.83, 0.62, 0.36); // warm metal (site accent family)
|
||
let diff = max(dot(n, L), 0.0);
|
||
let spec = pow(max(dot(n, H), 0.0), 48.0) * 0.6;
|
||
let amb = 0.18 + 0.12 * (n.y * 0.5 + 0.5); // soft sky ambient
|
||
let col = base * (amb + diff * 0.9) + vec3f(spec);
|
||
return vec4f(pow(col, vec3f(0.4545)), 1.0); // gamma
|
||
}`});
|
||
const meshPipeline = device.createRenderPipeline({
|
||
layout: 'auto',
|
||
vertex: { module: meshShader, entryPoint: 'vs',
|
||
buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }] },
|
||
fragment: { module: meshShader, entryPoint: 'fs', targets: [{ format }] },
|
||
primitive: { topology: 'triangle-list', cullMode: 'none' },
|
||
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
|
||
});
|
||
const mesh3dBind = device.createBindGroup({ layout: meshPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: cam3d } }] });
|
||
const meshVerts = gpuBuf(), meshIdx = gpuBuf();
|
||
function uploadIdx(slot, data) {
|
||
const bytes = data.byteLength;
|
||
if (bytes > slot.cap) {
|
||
slot.buf?.destroy();
|
||
slot.cap = Math.max(256, bytes * 2);
|
||
slot.buf = device.createBuffer({ size: slot.cap, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
|
||
}
|
||
if (bytes > 0) device.queue.writeBuffer(slot.buf, 0, data);
|
||
}
|
||
|
||
// ---- Gaussian splats: real EWA splatting. Each splat is an oriented
|
||
// 3D Gaussian (anisotropic covariance); the vertex shader projects its
|
||
// covariance to a 2D screen-space conic (Σ' = J·W·Σ·Wᵀ·Jᵀ), emits a
|
||
// tight bounding quad, and the fragment evaluates the conic. Sorted
|
||
// back-to-front on the CPU, alpha-blended. The textbook 3DGS pipeline.
|
||
const splatShader = device.createShaderModule({ code: /* wgsl */`
|
||
${CAM_WGSL}
|
||
struct VOut {
|
||
@builtin(position) clip: vec4f,
|
||
@location(0) d: vec2f, // pixel offset from the splat center
|
||
@location(1) conic: vec3f, // inverse 2D covariance (a, b, c)
|
||
@location(2) col: vec4f,
|
||
}
|
||
@vertex fn vs(@builtin(vertex_index) vi: u32,
|
||
@location(0) center: vec3f,
|
||
@location(1) covA: vec3f, // cxx, cxy, cxz
|
||
@location(2) covB: vec3f, // cyy, cyz, czz
|
||
@location(3) col: vec4f) -> VOut {
|
||
var corners = array<vec2f,6>(vec2f(-1,-1),vec2f(1,-1),vec2f(-1,1),vec2f(-1,1),vec2f(1,-1),vec2f(1,1));
|
||
let q = corners[vi];
|
||
var o: VOut;
|
||
let tv = cam.view * vec4f(center, 1.0);
|
||
let t = tv.xyz;
|
||
if (t.z >= -0.05) { o.clip = vec4f(2.0, 2.0, 2.0, 1.0); o.d = vec2f(0); o.conic = vec3f(1,0,1); o.col = vec4f(0); return o; }
|
||
let fx = cam.params.z; let fy = cam.params.w;
|
||
let z = t.z; let z2 = z * z;
|
||
// world→view rotation and the perspective Jacobian (3rd row 0).
|
||
let W = mat3x3f(cam.view[0].xyz, cam.view[1].xyz, cam.view[2].xyz);
|
||
let J = mat3x3f(
|
||
vec3f(fx / (-z), 0.0, 0.0),
|
||
vec3f(0.0, fy / (-z), 0.0),
|
||
vec3f(fx * t.x / z2, fy * t.y / z2, 0.0));
|
||
let cov3 = mat3x3f(
|
||
vec3f(covA.x, covA.y, covA.z),
|
||
vec3f(covA.y, covB.x, covB.y),
|
||
vec3f(covA.z, covB.y, covB.z));
|
||
let camCov = W * cov3 * transpose(W);
|
||
let c2 = J * camCov * transpose(J);
|
||
var a = c2[0][0] + 0.3; var b = c2[1][0]; var cc = c2[1][1] + 0.3; // low-pass
|
||
let det = a * cc - b * b;
|
||
if (det <= 0.0) { o.clip = vec4f(2.0, 2.0, 2.0, 1.0); o.d = vec2f(0); o.conic = vec3f(1,0,1); o.col = vec4f(0); return o; }
|
||
o.conic = vec3f(cc, -b, a) / det;
|
||
let mid = 0.5 * (a + cc);
|
||
let l1 = mid + sqrt(max(0.1, mid * mid - det));
|
||
let radius = ceil(3.0 * sqrt(l1)); // pixels
|
||
let clip = cam.proj * tv;
|
||
o.d = q * radius;
|
||
o.clip = vec4f(clip.xy + q * radius * 2.0 / cam.params.xy * clip.w, clip.zw);
|
||
o.col = col;
|
||
return o;
|
||
}
|
||
@fragment fn fs(in: VOut) -> @location(0) vec4f {
|
||
let power = -0.5 * (in.conic.x * in.d.x * in.d.x + in.conic.z * in.d.y * in.d.y)
|
||
- in.conic.y * in.d.x * in.d.y;
|
||
if (power > 0.0) { discard; }
|
||
let alpha = min(0.99, in.col.a * exp(power));
|
||
return vec4f(in.col.rgb * alpha, alpha); // premultiplied
|
||
}`});
|
||
const splatPipeline = device.createRenderPipeline({
|
||
layout: 'auto',
|
||
vertex: { module: splatShader, entryPoint: 'vs',
|
||
buffers: [{ arrayStride: 52, stepMode: 'instance', attributes: [
|
||
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
|
||
{ shaderLocation: 1, offset: 12, format: 'float32x3' },
|
||
{ shaderLocation: 2, offset: 24, format: 'float32x3' },
|
||
{ shaderLocation: 3, offset: 36, format: 'float32x4' },
|
||
] }] },
|
||
fragment: { module: splatShader, entryPoint: 'fs', targets: [{
|
||
format,
|
||
blend: { color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
|
||
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' } },
|
||
}] },
|
||
primitive: { topology: 'triangle-list' },
|
||
});
|
||
const splat3dBind = device.createBindGroup({ layout: splatPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: cam3d } }] });
|
||
const splatBuf = gpuBuf();
|
||
const SPLAT_FLOATS = 13; // center3, cov3d(6), rgba4
|
||
|
||
// Derive an ANISOTROPIC Gaussian-splat cloud from the verified mesh:
|
||
// each barycentric sample becomes a flat disc oriented to the triangle
|
||
// (wide in the surface tangent plane, thin along the normal) — the way
|
||
// a real splat capture of a flat surface looks. Per-splat 3×3 covariance
|
||
// Σ = sₜ²(t1t1ᵀ + t2t2ᵀ) + sₙ²(nnᵀ). Same content-addressed asset, a
|
||
// different sink-side rendering.
|
||
let splatCloud = null;
|
||
function buildSplatCloud(mesh, perTri = 64) {
|
||
const pos = mesh.positions, idx = mesh.triIndices;
|
||
const tris = idx.length / 3;
|
||
const n = tris * perTri;
|
||
const centers = new Float32Array(n * 3), inst = new Float32Array(n * SPLAT_FLOATS);
|
||
const st = 0.11, sn = 0.018, st2 = st * st, sn2 = sn * sn; // tangent / normal sigmas
|
||
let s = 12345 >>> 0; const rnd = () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296;
|
||
let k = 0;
|
||
for (let t = 0; t < tris; t++) {
|
||
const a = idx[t * 3], b = idx[t * 3 + 1], c = idx[t * 3 + 2];
|
||
const pa = [pos[a * 3], pos[a * 3 + 1], pos[a * 3 + 2]];
|
||
const pb = [pos[b * 3], pos[b * 3 + 1], pos[b * 3 + 2]];
|
||
const pc = [pos[c * 3], pos[c * 3 + 1], pos[c * 3 + 2]];
|
||
const t1 = norm3(sub3(pb, pa));
|
||
const nrm = norm3(cross3(sub3(pb, pa), sub3(pc, pa)));
|
||
const t2 = norm3(cross3(nrm, t1));
|
||
// symmetric covariance entries
|
||
const cxx = st2 * (t1[0] * t1[0] + t2[0] * t2[0]) + sn2 * nrm[0] * nrm[0];
|
||
const cyy = st2 * (t1[1] * t1[1] + t2[1] * t2[1]) + sn2 * nrm[1] * nrm[1];
|
||
const czz = st2 * (t1[2] * t1[2] + t2[2] * t2[2]) + sn2 * nrm[2] * nrm[2];
|
||
const cxy = st2 * (t1[0] * t1[1] + t2[0] * t2[1]) + sn2 * nrm[0] * nrm[1];
|
||
const cxz = st2 * (t1[0] * t1[2] + t2[0] * t2[2]) + sn2 * nrm[0] * nrm[2];
|
||
const cyz = st2 * (t1[1] * t1[2] + t2[1] * t2[2]) + sn2 * nrm[1] * nrm[2];
|
||
for (let j = 0; j < perTri; j++) {
|
||
let u = rnd(), v = rnd(); if (u + v > 1) { u = 1 - u; v = 1 - v; }
|
||
const w = 1 - u - v;
|
||
const x = pa[0] * w + pb[0] * u + pc[0] * v;
|
||
const y = pa[1] * w + pb[1] * u + pc[1] * v;
|
||
const z = pa[2] * w + pb[2] * u + pc[2] * v;
|
||
centers[k * 3] = x; centers[k * 3 + 1] = y; centers[k * 3 + 2] = z;
|
||
const o = k * SPLAT_FLOATS;
|
||
inst[o] = x; inst[o + 1] = y; inst[o + 2] = z;
|
||
inst[o + 3] = cxx; inst[o + 4] = cxy; inst[o + 5] = cxz;
|
||
inst[o + 6] = cyy; inst[o + 7] = cyz; inst[o + 8] = czz;
|
||
inst[o + 9] = 0.5 + x; inst[o + 10] = 0.6 + y * 0.8; inst[o + 11] = 0.7 - z; inst[o + 12] = 0.9;
|
||
k++;
|
||
}
|
||
}
|
||
return { centers, instances: inst, count: n };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sink lifecycle
|
||
// ---------------------------------------------------------------------------
|
||
|
||
await init();
|
||
|
||
let sink = null, playing = true, done = false;
|
||
let live = null; // live-session state (playground mode), else null
|
||
|
||
function closeLive() {
|
||
if (live) { live.transport?.close(); live = null; }
|
||
ui.actors.textContent = '—';
|
||
ui.sync.textContent = '—';
|
||
}
|
||
|
||
async function load(name) {
|
||
closeLive();
|
||
assetMesh = null;
|
||
splatCloud = null;
|
||
computeMs = 0;
|
||
ui.assets.textContent = '—';
|
||
// gallery / gallery_splat both load the same verified scene; the
|
||
// suffix only chooses the sink-side rendering.
|
||
const file = name.startsWith('gallery') ? 'gallery' : name;
|
||
renderMode = name === 'gallery' ? 'mesh' : name === 'gallery_splat' ? 'splat' : 'sim';
|
||
const resp = await fetch(`vectors/${file}.wwld`);
|
||
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||
sink = new WorldSink(bytes);
|
||
done = false;
|
||
playing = true;
|
||
ui.badge.className = '';
|
||
ui.badge.textContent = '';
|
||
ui.pause.textContent = '⏸ pause';
|
||
ui.sim.textContent = sink.sim().replace('wai.world.sim.', '');
|
||
ui.final.textContent = sink.final_tick().toString();
|
||
ui.bytes.textContent = bytes.length.toString();
|
||
// Only the playground is a live, authored session; other scenes
|
||
// (e.g. gallery) are static — they render, they don't open a transport.
|
||
if (name === 'playground') await startLive(name);
|
||
await loadAssets();
|
||
}
|
||
|
||
// Fetch, content-hash-verify, and decode the world's wai.mesh.glb
|
||
// assets (extension §4.1). The verify happens in WASM (asset_glb
|
||
// rejects a hash mismatch); the GLB decode is the page's job — WAI
|
||
// dispatches, it does not decode meshes.
|
||
async function loadAssets() {
|
||
let list;
|
||
try { list = JSON.parse(sink.assets()); } catch { list = []; }
|
||
if (!list.length) return;
|
||
const fetchAsset = async (a, cap) => {
|
||
const env = new Uint8Array(await (await fetch(`vectors/assets/${a.name}.wai`)).arrayBuffer());
|
||
return sink.asset_payload(a.name, env, cap); // throws on hash/capability mismatch
|
||
};
|
||
const glb = list.find(a => a.name.endsWith('.glb'));
|
||
const spz = list.find(a => a.name.endsWith('.spz'));
|
||
try {
|
||
if (renderMode === 'splat') {
|
||
// Prefer the real wai.splat.spz asset: fetch, hash-verify, gunzip
|
||
// + decode the actual SPZ bitstream. Fall back to a mesh-derived
|
||
// cloud only if no splat asset is present.
|
||
if (spz) {
|
||
splatCloud = await decodeSpz(await fetchAsset(spz, 'wai.splat.spz'));
|
||
upload(splatBuf, splatCloud.instances);
|
||
ui.assets.textContent = `1/1 verified ✓ · ${spz.name} · ${splatCloud.count} real Gaussians (SPZ decoded)`;
|
||
return;
|
||
}
|
||
assetMesh = decodeGlb(await fetchAsset(glb, 'wai.mesh.glb'));
|
||
splatCloud = buildSplatCloud(assetMesh);
|
||
upload(splatBuf, splatCloud.instances);
|
||
ui.assets.textContent = `verified ✓ · ${splatCloud.count} splats (mesh-derived)`;
|
||
return;
|
||
}
|
||
// mesh mode
|
||
assetMesh = decodeGlb(await fetchAsset(glb, 'wai.mesh.glb'));
|
||
upload(meshVerts, assetMesh.positions);
|
||
uploadIdx(meshIdx, assetMesh.triIndices);
|
||
ui.assets.textContent = `1/1 verified ✓ · ${glb.name} · PBR mesh`;
|
||
} catch (e) {
|
||
console.warn('asset:', e);
|
||
ui.assets.textContent = '✗ ' + (e.message || e);
|
||
}
|
||
}
|
||
|
||
// Real SPZ v3 decoder: gunzip (DecompressionStream), parse the
|
||
// 16-byte header, unpack the non-interleaved arrays (24-bit fixed-point
|
||
// positions, log scales, smallest-three quaternion, opacity/color =
|
||
// byte/255), and build per-Gaussian world covariance Σ = R·diag(σ²)·Rᵀ
|
||
// for the EWA splat pipeline. sh_degree 0 (no SH bands).
|
||
async function decodeSpz(u8) {
|
||
const stream = new Blob([u8]).stream().pipeThrough(new DecompressionStream('gzip'));
|
||
const buf = new Uint8Array(await new Response(stream).arrayBuffer());
|
||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||
if (dv.getInt32(0, true) !== 0x5053474e) throw new Error('not an SPZ file');
|
||
const n = dv.getInt32(8, true);
|
||
const fbits = buf[13];
|
||
let o = 16;
|
||
const pos = buf.subarray(o, o += n * 9);
|
||
const alpha = buf.subarray(o, o += n);
|
||
const col = buf.subarray(o, o += n * 3);
|
||
const scl = buf.subarray(o, o += n * 3);
|
||
const rot = buf.subarray(o, o += n * 4);
|
||
const fixedScale = 1 / (1 << fbits);
|
||
const centers = new Float32Array(n * 3);
|
||
const inst = new Float32Array(n * SPLAT_FLOATS);
|
||
for (let i = 0; i < n; i++) {
|
||
const p = [0, 0, 0];
|
||
for (let k = 0; k < 3; k++) {
|
||
let f = pos[i * 9 + k * 3] | (pos[i * 9 + k * 3 + 1] << 8) | (pos[i * 9 + k * 3 + 2] << 16);
|
||
if (f & 0x800000) f |= ~0xffffff; // sign-extend 24-bit
|
||
p[k] = f * fixedScale;
|
||
}
|
||
const sg = [
|
||
Math.exp(scl[i * 3] / 16 - 10),
|
||
Math.exp(scl[i * 3 + 1] / 16 - 10),
|
||
Math.exp(scl[i * 3 + 2] / 16 - 10),
|
||
];
|
||
const q = unpackQuat(rot, i * 4);
|
||
const cov = covFromQuatScale(q, sg);
|
||
centers[i * 3] = p[0]; centers[i * 3 + 1] = p[1]; centers[i * 3 + 2] = p[2];
|
||
const b = i * SPLAT_FLOATS;
|
||
inst[b] = p[0]; inst[b + 1] = p[1]; inst[b + 2] = p[2];
|
||
inst[b + 3] = cov[0]; inst[b + 4] = cov[1]; inst[b + 5] = cov[2];
|
||
inst[b + 6] = cov[3]; inst[b + 7] = cov[4]; inst[b + 8] = cov[5];
|
||
inst[b + 9] = col[i * 3] / 255; inst[b + 10] = col[i * 3 + 1] / 255;
|
||
inst[b + 11] = col[i * 3 + 2] / 255; inst[b + 12] = alpha[i] / 255;
|
||
}
|
||
return { centers, instances: inst, count: n };
|
||
}
|
||
|
||
// SPZ smallest-three quaternion (4 bytes) → [x, y, z, w].
|
||
function unpackQuat(r, off) {
|
||
let comp = (r[off] | (r[off + 1] << 8) | (r[off + 2] << 16) | (r[off + 3] << 24)) >>> 0;
|
||
const iLargest = comp >>> 30;
|
||
const q = [0, 0, 0, 0];
|
||
let sumsq = 0;
|
||
for (let i = 3; i >= 0; i--) {
|
||
if (i === iLargest) continue;
|
||
const mag = comp & 0x1ff;
|
||
const neg = (comp >>> 9) & 1;
|
||
comp >>>= 10;
|
||
let v = (Math.SQRT1_2 * mag) / 511;
|
||
if (neg) v = -v;
|
||
q[i] = v;
|
||
sumsq += v * v;
|
||
}
|
||
q[iLargest] = Math.sqrt(Math.max(0, 1 - sumsq));
|
||
return q;
|
||
}
|
||
|
||
// World covariance Σ = R·diag(σ²)·Rᵀ → 6 symmetric floats (cxx,cxy,cxz,cyy,cyz,czz).
|
||
function covFromQuatScale(q, sg) {
|
||
const [x, y, z, w] = q;
|
||
const r = [
|
||
1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y),
|
||
2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x),
|
||
2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y),
|
||
];
|
||
const s = [sg[0] * sg[0], sg[1] * sg[1], sg[2] * sg[2]];
|
||
const m = (a, b) => r[a] * r[b] * s[0] + r[a + 1] * r[b + 1] * s[1] + r[a + 2] * r[b + 2] * s[2];
|
||
// rows 0,1,2 start at 0,3,6
|
||
return [m(0, 0), m(0, 3), m(0, 6), m(3, 3), m(3, 6), m(6, 6)];
|
||
}
|
||
|
||
// Minimal glTF 2.0 Binary decoder: first mesh primitive's POSITION +
|
||
// indices. Slices into fresh buffers so typed-array alignment is never
|
||
// an issue regardless of where the WASM payload sits.
|
||
function decodeGlb(u8) {
|
||
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
||
if (dv.getUint32(0, true) !== 0x46546c67) throw new Error('not a glb');
|
||
const jsonLen = dv.getUint32(12, true);
|
||
const json = JSON.parse(new TextDecoder().decode(u8.subarray(20, 20 + jsonLen)));
|
||
const binStart = 20 + jsonLen + 8; // chunk0 data + chunk1 (len+type)
|
||
const bin = u8.subarray(binStart);
|
||
const prim = json.meshes[0].primitives[0];
|
||
const acc = json.accessors, bv = json.bufferViews;
|
||
const pAcc = acc[prim.attributes.POSITION], iAcc = acc[prim.indices];
|
||
const pBV = bv[pAcc.bufferView], iBV = bv[iAcc.bufferView];
|
||
const pOff = (pBV.byteOffset || 0), iOff = (iBV.byteOffset || 0);
|
||
const positions = new Float32Array(bin.slice(pOff, pOff + pAcc.count * 12).buffer);
|
||
const indices = new Uint16Array(bin.slice(iOff, iOff + iAcc.count * 2).buffer);
|
||
// unique edges from the triangle soup
|
||
const seen = new Set(), edges = [];
|
||
for (let t = 0; t < indices.length; t += 3) {
|
||
for (const [x, y] of [[0, 1], [1, 2], [2, 0]]) {
|
||
const a = indices[t + x], b = indices[t + y];
|
||
const key = a < b ? a * 65536 + b : b * 65536 + a;
|
||
if (!seen.has(key)) { seen.add(key); edges.push(a, b); }
|
||
}
|
||
}
|
||
return { positions, edges: new Uint16Array(edges), triIndices: indices };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Live mode: this tab is an actor; tabs converge over BroadcastChannel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Transport selection: the relay (wss — cross-device, room in the URL
|
||
/// hash) when reachable, BroadcastChannel (same-browser tabs) otherwise.
|
||
/// Either way the payload is identical signed-op envelopes — transport
|
||
/// is out of the extension's scope, and convergence never depends on it.
|
||
function connectTransport(name, onMessage) {
|
||
return new Promise((resolve) => {
|
||
const room = ((location.hash.slice(1) || 'lobby').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 48) || 'lobby') + '-' + name;
|
||
let settled = false;
|
||
const fallback = () => {
|
||
if (settled) return; settled = true;
|
||
const chan = new BroadcastChannel(`wai-world-${name}`);
|
||
chan.onmessage = (ev) => onMessage(ev.data);
|
||
resolve({ kind: 'tabs', send: (m) => chan.postMessage(m), close: () => chan.close() });
|
||
};
|
||
let ws;
|
||
try {
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
ws = new WebSocket(`${proto}://${location.host}/worlds-ws/${room}`);
|
||
} catch { return fallback(); }
|
||
const timer = setTimeout(() => { try { ws.close(); } catch {} fallback(); }, 2500);
|
||
ws.onerror = () => { if (!settled) { clearTimeout(timer); fallback(); } };
|
||
ws.onopen = () => {
|
||
clearTimeout(timer); settled = true;
|
||
ws.onmessage = (ev) => onMessage(JSON.parse(ev.data));
|
||
resolve({
|
||
kind: 'relay',
|
||
send: (m) => { if (ws.readyState === 1) ws.send(JSON.stringify(m)); },
|
||
close: () => ws.close(),
|
||
});
|
||
};
|
||
});
|
||
}
|
||
|
||
async function startLive(name) {
|
||
const seed = crypto.getRandomValues(new Uint8Array(32));
|
||
const actor = new Actor(seed);
|
||
live = {
|
||
actor,
|
||
pub: actor.pubkey_hex(),
|
||
epoch: Date.now(), // provisional; the smallest epoch wins (the relay's, if present)
|
||
send: () => {},
|
||
transport: null,
|
||
ops: [], // hex op log (for late joiners on the tabs fallback)
|
||
actors: new Set(), // pubkeys seen (incl. self)
|
||
hashes: new Map(), // our boundary-tick hashes (tick → hex)
|
||
peerOk: null,
|
||
rewound: false,
|
||
};
|
||
live.actors.add(live.pub);
|
||
|
||
const onMessage = (m) => {
|
||
if (!live) return;
|
||
if (m.t === 'hello') {
|
||
live.send({ t: 'sync', epoch: live.epoch, ops: live.ops, from: live.pub });
|
||
} else if (m.t === 'sync') {
|
||
if (m.from && m.from !== 'relay') live.actors.add(m.from);
|
||
if (m.epoch < live.epoch) { live.epoch = m.epoch; live.hashes.clear(); }
|
||
for (const hex of m.ops) ingestHex(hex, m.from);
|
||
} else if (m.t === 'op') {
|
||
live.actors.add(m.from);
|
||
ingestHex(m.bytes, m.from);
|
||
} else if (m.t === 'hash') {
|
||
live.actors.add(m.from);
|
||
const ours = live.hashes.get(m.tick);
|
||
if (ours !== undefined) {
|
||
live.peerOk = ours === m.hash ? { tick: m.tick } : false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const transport = await connectTransport(name, onMessage);
|
||
if (!live) { transport.close(); return; } // vector switched mid-connect
|
||
live.transport = transport;
|
||
live.send = transport.send;
|
||
live.send({ t: 'hello' });
|
||
|
||
ui.badge.className = 'ok';
|
||
ui.badge.textContent = transport.kind === 'relay'
|
||
? '● live via relay — open this URL on ANY device and shove the tower'
|
||
: '● live (tab-local) — open this page in a second tab and shove the tower';
|
||
}
|
||
|
||
function ingestHex(hexOp, from) {
|
||
const bytes = fromHex(hexOp);
|
||
try {
|
||
live.actor.observe_lamport(op_lamport(bytes));
|
||
sink.ingest_op(bytes); // signature verified inside the engine
|
||
if (!live.ops.includes(hexOp)) live.ops.push(hexOp);
|
||
live.rewound = true; // conservatively drop stale boundary hashes
|
||
live.hashes.clear();
|
||
live.actors.add(from);
|
||
} catch (e) {
|
||
console.warn('op rejected:', e);
|
||
}
|
||
}
|
||
|
||
function liveTargetTick() {
|
||
return BigInt(Math.max(1, Math.floor((Date.now() - live.epoch) / (1000 / TICK_HZ))));
|
||
}
|
||
|
||
canvas.addEventListener('pointerdown', (ev) => {
|
||
if (!live || !sink) return;
|
||
// canvas pixel → world coordinates (account for CSS scaling)
|
||
const rect = canvas.getBoundingClientRect();
|
||
const wx = WORLD.xMin + ((ev.clientX - rect.left) / rect.width) * (WORLD.xMax - WORLD.xMin);
|
||
const wy = WORLD.yMax - ((ev.clientY - rect.top) / rect.height) * (WORLD.yMax - WORLD.yMin);
|
||
// nearest particle
|
||
const p = sink.particles();
|
||
const ids = sink.particle_ids();
|
||
let best = -1, bestD = Infinity;
|
||
for (let i = 0; i * 5 < p.length; i++) {
|
||
const dx = p[i * 5] - wx, dy = p[i * 5 + 1] - wy;
|
||
const d = dx * dx + dy * dy;
|
||
if (d < bestD) { bestD = d; best = i; }
|
||
}
|
||
if (best < 0 || bestD > 9) return; // ignore clicks far from the structure
|
||
const id = ids.slice(best * 16, best * 16 + 16);
|
||
// shove the particle away from the click point
|
||
const px = p[best * 5], py = p[best * 5 + 1];
|
||
let dx = px - wx, dy = py - wy;
|
||
const len = Math.hypot(dx, dy) || 1;
|
||
// Demolition is precision work: force falls off with distance. A
|
||
// point-blank click out-runs the member yield (PBD projection resets
|
||
// velocity each tick, so only the instantaneous impulse matters);
|
||
// a distant click just makes the structure sway.
|
||
const mag = Math.min(34, SHOVE / Math.max(len, 0.5));
|
||
dx = (dx / len) * mag; dy = (dy / len) * mag;
|
||
const effect = liveTargetTick() + BigInt(OP_LEAD_TICKS);
|
||
const opBytes = live.actor.sign_truss_impulse(effect, id, dx, dy, 0);
|
||
const hexOp = toHex(opBytes);
|
||
ingestHex(hexOp, live.pub);
|
||
live.send({ t: 'op', bytes: hexOp, from: live.pub });
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// UI wiring + frame loop
|
||
// ---------------------------------------------------------------------------
|
||
|
||
ui.vector.addEventListener('change', () => load(ui.vector.value));
|
||
ui.restart.addEventListener('click', () => load(ui.vector.value));
|
||
ui.pause.addEventListener('click', () => {
|
||
playing = !playing;
|
||
ui.pause.textContent = playing ? '⏸ pause' : '▶ play';
|
||
});
|
||
|
||
function download(name, bytes, mime) {
|
||
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = name; a.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||
}
|
||
|
||
// Seal the session as it stands into a wai.world.replay + its §8
|
||
// receipt, prove the replay re-verifies in a throwaway sink, and
|
||
// download both. The whole point: a live, shared, edited world
|
||
// becomes a portable artifact anyone can re-derive and check.
|
||
// Per-world receipt chain head, persisted so successive seals of the
|
||
// same world form one append-only signed timeline (parent_receipt_hash).
|
||
const CHAIN_KEY = (worldHash) => `wai-chain-${worldHash}`;
|
||
function chainHead(worldHash) {
|
||
try { return JSON.parse(localStorage.getItem(CHAIN_KEY(worldHash))) || null; } catch { return null; }
|
||
}
|
||
|
||
// Honest energy figure: work_steps is the EXACT signed quantity; joules
|
||
// is an ESTIMATE, wall-clock compute times a declared power coefficient
|
||
// (a page reads no energy counter). The receipt seals it with the class
|
||
// Estimator and the page shows it as an estimate, so it is never
|
||
// mistaken for a measurement.
|
||
const WATTS = 4.0; // declared coefficient for the estimate
|
||
let computeMs = 0; // accumulated wall-clock in the deterministic step loop
|
||
|
||
ui.seal.addEventListener('click', () => {
|
||
if (!sink) return;
|
||
try {
|
||
const seed = crypto.getRandomValues(new Uint8Array(32));
|
||
const signerId = live ? `did:key:wai-${live.pub.slice(0, 16)}` : 'did:example:wai-world';
|
||
const worldHash = sink.world_hash_hex();
|
||
const head = chainHead(worldHash);
|
||
const groupId = head ? BigInt(head.group_id + 1) : 0n;
|
||
const parentHex = head ? head.receipt_hash : '';
|
||
const joulesMicro = BigInt(Math.round((computeMs / 1000) * WATTS * 1e6));
|
||
const sealed = sink.seal_session(seed, signerId, joulesMicro, groupId, parentHex);
|
||
const tag = `${ui.vector.value}-t${sealed.final_tick()}-g${groupId}`;
|
||
// Proof: re-open the freshly sealed replay in a fresh sink and verify.
|
||
const replay = sealed.replay_bytes();
|
||
const reverified = new WorldSink(replay).verify_seal();
|
||
download(`${tag}.wwld`, replay, 'application/octet-stream');
|
||
download(`${tag}.receipt.json`, sealed.receipt_json(), 'application/json');
|
||
// Advance the world's chain head.
|
||
localStorage.setItem(CHAIN_KEY(worldHash),
|
||
JSON.stringify({ receipt_hash: sealed.receipt_hash(), group_id: Number(groupId) }));
|
||
const mJ = (Number(joulesMicro) / 1000).toFixed(1);
|
||
ui.receipt.textContent =
|
||
`chain #${groupId}${head ? ' ↳linked' : ''} · root ${sealed.root_hash().slice(0, 10)}… · `
|
||
+ `${sealed.work_steps()} steps · ≈${mJ} mJ (Estimator: wall time × ${WATTS} W, not measured) · ${reverified ? '✓ re-verified' : '✗ FAILED'}`;
|
||
} catch (e) {
|
||
ui.receipt.textContent = '✗ ' + (e.message || e);
|
||
}
|
||
});
|
||
|
||
await load(ui.vector.value);
|
||
|
||
function stepReplay() {
|
||
const t = sink.current_tick();
|
||
const finalTick = sink.final_tick();
|
||
if (t < finalTick) {
|
||
{ const _t = performance.now(); sink.advance_to(t + 1n); computeMs += performance.now() - _t; }
|
||
} else {
|
||
const ok = sink.verify_seal();
|
||
ui.badge.className = ok ? 'ok' : 'bad';
|
||
ui.badge.textContent = ok
|
||
? '✓ replay verified — state hash matches the publisher seal'
|
||
: '✗ HASH MISMATCH — nonconformant replay';
|
||
done = true;
|
||
if (ok) setTimeout(() => { if (done) load(ui.vector.value); }, 3000);
|
||
}
|
||
}
|
||
|
||
function stepLive() {
|
||
const target = liveTargetTick();
|
||
let t = sink.current_tick();
|
||
// Land exactly on hash-exchange boundaries as we pass them.
|
||
while (t < target) {
|
||
const nextBoundary = ((t / BigInt(HASH_EVERY)) + 1n) * BigInt(HASH_EVERY);
|
||
const stop = nextBoundary < target ? nextBoundary : target;
|
||
{ const _t = performance.now(); sink.advance_to(stop); computeMs += performance.now() - _t; }
|
||
t = stop;
|
||
if (t % BigInt(HASH_EVERY) === 0n) {
|
||
const h = sink.state_hash_hex();
|
||
live.hashes.set(t.toString(), h);
|
||
if (live.hashes.size > 12) live.hashes.delete(live.hashes.keys().next().value);
|
||
live.send({ t: 'hash', tick: t.toString(), hash: h, from: live.pub });
|
||
}
|
||
}
|
||
ui.actors.textContent = live.actors.size.toString();
|
||
ui.sync.textContent =
|
||
live.actors.size < 2 ? 'waiting for a second tab'
|
||
: live.peerOk === null ? 'comparing…'
|
||
: live.peerOk === false ? '✗ diverged'
|
||
: `✓ hashes agree @ tick ${live.peerOk.tick}`;
|
||
}
|
||
|
||
function frame() {
|
||
if (sink && playing && !done) {
|
||
// live → step the multiplayer clock; sealed → replay+verify;
|
||
// static scene (final_tick 0, no live) → nothing to advance.
|
||
if (live) stepLive();
|
||
else if (sink.final_tick() > 0n) stepReplay();
|
||
ui.tick.textContent = sink.current_tick().toString();
|
||
ui.nodes.textContent = sink.node_count().toString();
|
||
ui.hash.textContent = sink.state_hash_hex().slice(0, 16) + '…';
|
||
}
|
||
|
||
const enc = device.createCommandEncoder();
|
||
const view = ctx.getCurrentTexture().createView();
|
||
const clear = { r: 0.016, g: 0.024, b: 0.04, a: 1 };
|
||
|
||
if (renderMode === 'mesh' || renderMode === 'splat') {
|
||
// 3D path: the verified asset, rendered as a solid PBR cube or a
|
||
// Gaussian-splat cloud — presentation only, state hash untouched.
|
||
const cam = camera(performance.now() / 2500);
|
||
const eye = cam.eye;
|
||
const fy = canvas.height / (2 * Math.tan(FOVY / 2)); // focal in px
|
||
const fx = fy; // square pixels; perspective uses aspect, focals match in px
|
||
const camData = new Float32Array(64); // 256 bytes
|
||
camData.set(cam.vp, 0);
|
||
camData.set(cam.view, 16);
|
||
camData.set(cam.proj, 32);
|
||
camData[48] = eye[0]; camData[49] = eye[1]; camData[50] = eye[2];
|
||
camData[52] = canvas.width; camData[53] = canvas.height; camData[54] = fx; camData[55] = fy;
|
||
device.queue.writeBuffer(cam3d, 0, camData);
|
||
|
||
const pass = enc.beginRenderPass({
|
||
colorAttachments: [{ view, clearValue: clear, loadOp: 'clear', storeOp: 'store' }],
|
||
depthStencilAttachment: renderMode === 'mesh' ? {
|
||
view: depthTex.createView(), depthClearValue: 1.0,
|
||
depthLoadOp: 'clear', depthStoreOp: 'store',
|
||
} : undefined,
|
||
});
|
||
if (renderMode === 'mesh' && assetMesh && meshIdx.buf) {
|
||
pass.setPipeline(meshPipeline);
|
||
pass.setBindGroup(0, mesh3dBind);
|
||
pass.setVertexBuffer(0, meshVerts.buf);
|
||
pass.setIndexBuffer(meshIdx.buf, 'uint16');
|
||
pass.drawIndexed(assetMesh.triIndices.length);
|
||
} else if (renderMode === 'splat' && splatCloud) {
|
||
// Back-to-front sort by view-space depth each frame (painter's
|
||
// order for correct alpha-blended Gaussians).
|
||
sortSplats(eye);
|
||
pass.setPipeline(splatPipeline);
|
||
pass.setBindGroup(0, splat3dBind);
|
||
pass.setVertexBuffer(0, splatBuf.buf);
|
||
pass.draw(6, splatCloud.count);
|
||
}
|
||
pass.end();
|
||
ui.tick.textContent = sink ? sink.current_tick().toString() : '0';
|
||
device.queue.submit([enc.finish()]);
|
||
requestAnimationFrame(frame);
|
||
return;
|
||
}
|
||
|
||
// 2D sim path (playground / replay vectors).
|
||
const p = sink ? sink.particles() : new Float32Array(0);
|
||
const m = sink ? sink.members() : new Float32Array(0);
|
||
upload(circles, p);
|
||
upload(lines, m);
|
||
|
||
const pass = enc.beginRenderPass({
|
||
colorAttachments: [{ view, clearValue: clear, loadOp: 'clear', storeOp: 'store' }],
|
||
});
|
||
pass.setPipeline(groundPipeline);
|
||
pass.setBindGroup(0, groundBind);
|
||
pass.draw(2);
|
||
if (m.length > 0) {
|
||
pass.setPipeline(linePipeline);
|
||
pass.setBindGroup(0, lineBind);
|
||
pass.setVertexBuffer(0, lines.buf);
|
||
pass.draw(m.length / 3);
|
||
}
|
||
if (p.length > 0) {
|
||
pass.setPipeline(circPipeline);
|
||
pass.setBindGroup(0, circBind);
|
||
pass.setVertexBuffer(0, circles.buf);
|
||
pass.draw(6, p.length / 5);
|
||
}
|
||
pass.end();
|
||
device.queue.submit([enc.finish()]);
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
// Re-order the splat instance buffer back-to-front from the camera so
|
||
// alpha blending composites correctly (the painter's algorithm — the
|
||
// CPU half of a Gaussian-splat renderer; the GPU half is the shader).
|
||
let splatOrder = null;
|
||
function sortSplats(eye) {
|
||
const c = splatCloud.centers, n = splatCloud.count;
|
||
if (!splatOrder || splatOrder.length !== n) splatOrder = Array.from({ length: n }, (_, i) => i);
|
||
const d = new Float32Array(n);
|
||
for (let i = 0; i < n; i++) {
|
||
const dx = c[i * 3] - eye[0], dy = c[i * 3 + 1] - eye[1], dz = c[i * 3 + 2] - eye[2];
|
||
d[i] = dx * dx + dy * dy + dz * dz;
|
||
}
|
||
splatOrder.sort((a, b) => d[b] - d[a]); // far → near
|
||
const src = splatCloud.instances, F = SPLAT_FLOATS, out = new Float32Array(n * F);
|
||
for (let i = 0; i < n; i++) out.set(src.subarray(splatOrder[i] * F, splatOrder[i] * F + F), i * F);
|
||
upload(splatBuf, out);
|
||
}
|
||
|
||
requestAnimationFrame(frame);
|