jwp-receipts §7: a did:web signer_id names a web origin, which publishes a key log at /.well-known/wai-keys: revisions signed by a root key that pre-commits to its successor, listing the origin's keys, the authorities each holds, and whether each is active, retired or revoked. A verifier that has anchored the origin, pinned or fetched, confirms an accepted receipt, claim or module grant against the log offline, and reports the outcome beside the item's own verdict, never in it. Hardware-class energy, lawful-basis and pre-emption authorities are confirmed only under a pinned anchor. Revocation refuses every item under a revoked key. §7.9 authorises decoder and reconstruct modules, and pinned parameter sets, by SHA-256 digest through signed module grants from the sink's module origins, revokes them by digest in the key log, and bounds how long a revoked module keeps running by grant_max_s + status_max_age_s, under ceilings of 86400 s and 3600 s. Every failure, offline included, leaves the module not held and dispatch falls back. wai-rs gains signer_id (ungated) and key_log (feature key_discovery), the wai_signer_vectors and wai-keys binaries, and the signer-conformance corpus with an independent Python verifier. The reference meters and the world demo sign under labels. A section-reference guard checks that every section SPEC.md or an extension cites in another of them exists. This commit is the stream as merged onto link 14. The commits after it reconcile it with the parameter-set pin and the energy reports. Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 from measured wall-clock compute at a declared power
|
||
// coefficient (a browser can't read RAPL — native sinks supply real
|
||
// joules via the sink-metering path). Labeled "est" so it's 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 est@${WATTS}W · ${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);
|