// Node-vs-records parity probe: the same deterministic sprite population
// rendered through the node-backed live layer (spatial arena nodes, pose
// buffer core-written) and the records layer (13 JS-owned floats), side by
// side. Self-asserting: after the first frames it readTexture-compares the
// two outputs pixel by pixel (the pose round-trips through the core's
// Pose2D decomposition, so only alpha-edge pixels may flip), cross-checks
// picking at random points, exercises pickRect, groups and slot recycling,
// and benches addSprite plus a move-everything frame on both layers. Watch
// the logs for PARITY/BENCH lines ending in PARITY-OK.
import { decodeImage, onFrame, render } from "@solidrt/core"
import { readTexture } from "@solidrt/core/gpu"
import {
addGroup,
addSprite,
createAtlas,
createRecordLayer,
createSpriteLayer,
getSprite,
grid,
destroySprite,
setGroup,
setSprite,
} from "@solidrt/2d"
import type { SpriteHandle } from "@solidrt/2d"
import logoBytes from "./logo.png" with { type: "binary" }
const N = 200
const W = 360
const H = 360
const SEED = 0x2d2d
// Deterministic PRNG (mulberry32) so both layers see identical sprites.
function rng(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function App() {
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
let frames = grid(atlas, 2, 2)
let nodes = createSpriteLayer([atlas], { capacity: 64, label: "nodes" })
let records = createRecordLayer([atlas], { capacity: 64, label: "records" })
let nodeView = nodes.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1], label: "nodes" })
let recordView = records.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1], label: "records" })
let fields = (r: () => number) => ({
x: 20 + r() * (W - 40),
y: 20 + r() * (H - 40),
w: 16 + r() * 48,
h: 16 + r() * 48,
rotation: r() * Math.PI * 2,
frame: frames[Math.floor(r() * 4) % 4]!,
tint: [0.5 + r() * 0.5, 0.5 + r() * 0.5, 0.5 + r() * 0.5, 1] as [number, number, number, number],
})
// Population (the capacity of 64 forces both growth paths, including the
// pose-buffer retarget).
let nodeSprites: SpriteHandle[] = []
let recSprites: SpriteHandle[] = []
let r1 = rng(SEED)
let t0 = performance.now()
for (let i = 0; i < N; i++) nodeSprites.push(addSprite(nodes, fields(r1)))
let tNodesAdd = performance.now() - t0
let r2 = rng(SEED)
t0 = performance.now()
for (let i = 0; i < N; i++) recSprites.push(addSprite(records, fields(r2)))
let tRecsAdd = performance.now() - t0
console.log(`BENCH addSprite x${N}: nodes ${tNodesAdd.toFixed(2)}ms, records ${tRecsAdd.toFixed(2)}ms`)
let failures: string[] = []
let check = (ok: boolean, what: string) => {
if (!ok) failures.push(what)
console.log(`PARITY ${ok ? "ok" : "FAIL"}: ${what}`)
}
// Slot recycling: remove one, add another, the layer stays coherent.
{
let extra = addSprite(nodes, { x: 10, y: 10, w: 8, h: 8 })
let slotBefore = extra._slot
destroySprite(extra)
let reused = addSprite(nodes, { x: -100, y: -100, w: 0, h: 0 })
check(reused._slot === slotBefore, "removed slot recycles to the next add")
check(getSprite(extra) === null, "removed handle is inert")
destroySprite(reused)
}
let pixelParity = () => {
let a = readTexture(nodeView.texture)
let b = readTexture(recordView.texture)
if (a.width !== b.width || a.height !== b.height) {
check(false, `sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`)
return
}
let off = 0
for (let i = 0; i < a.data.length; i += 4) {
if (
Math.abs(a.data[i]! - b.data[i]!) > 8 ||
Math.abs(a.data[i + 1]! - b.data[i + 1]!) > 8 ||
Math.abs(a.data[i + 2]! - b.data[i + 2]!) > 8 ||
Math.abs(a.data[i + 3]! - b.data[i + 3]!) > 8
) {
off++
}
}
let total = a.data.length / 4
let pct = (off / total) * 100
check(pct < 0.5, `pixels within tolerance (${off}/${total} off, ${pct.toFixed(3)}%)`)
}
let pickParity = () => {
let r = rng(SEED ^ 0xffff)
let byNode = new Map(nodeSprites.map((s, i) => [s, i]))
let byRec = new Map(recSprites.map((s, i) => [s, i]))
let mismatches = 0
for (let i = 0; i < 200; i++) {
let x = r() * W
let y = r() * H
let a = nodes.pick(x, y)[0] ?? null
let b = records.pick(x, y)[0] ?? null
let ai = a ? byNode.get(a) : null
let bi = b ? byRec.get(b) : null
if (ai !== bi) mismatches++
}
check(mismatches <= 1, `pick agrees at random points (${mismatches} mismatches)`)
}
let rectQuery = () => {
// Left half of the layer: every hit must at least touch it by AABB,
// every sprite fully inside it must be reported.
let hits = new Set(nodes.pickRect(0, 0, W / 2, H))
let missed = 0
let phantom = 0
for (let s of nodeSprites) {
let f = getSprite(s)!
let radius = (Math.hypot(f.w, f.h) / 2) * 1.001
let inside = f.x + radius <= W / 2 && f.x - radius >= 0 && f.y - radius >= 0 && f.y + radius <= H
let touches = f.x - radius <= W / 2
if (inside && !hits.has(s)) missed++
if (!touches && hits.has(s)) phantom++
}
check(missed === 0 && phantom === 0, `pickRect covers the left half (${hits.size} hits, ${missed} missed, ${phantom} phantom)`)
}
let groups = () => {
// A group at (100, 100) rotated 90deg clockwise: a child sprite at
// local (50, 0) lands at world (100, 150) in y-down space.
let g = addGroup(nodes, { x: 100, y: 100 })
let child = addSprite(nodes, { parent: g, x: 50, y: 0, w: 10, h: 10 })
setGroup(g, { rotation: Math.PI / 2 })
queueMicrotask(() => {
check(nodes.pick(100, 150)[0] === child, "group rotation carries the child sprite")
check(nodes.pick(150, 100)[0] !== child, "the ungrouped position no longer hits")
let f = getSprite(child)!
check(f.x === 50 && f.y === 0, "getSprite reads the local pose")
destroySprite(child)
// The group node stays for the layer to dispose.
})
}
let bench = () => {
let r = rng(SEED ^ 0xabcd)
let t = performance.now()
for (let i = 0; i < N; i++) setSprite(nodeSprites[i]!, { x: 20 + r() * (W - 40), y: 20 + r() * (H - 40) })
let tNodes = performance.now() - t
r = rng(SEED ^ 0xabcd)
t = performance.now()
for (let i = 0; i < N; i++) setSprite(recSprites[i]!, { x: 20 + r() * (W - 40), y: 20 + r() * (H - 40) })
let tRecs = performance.now() - t
console.log(`BENCH move x${N}: nodes ${tNodes.toFixed(2)}ms, records ${tRecs.toFixed(2)}ms`)
// Put both populations back so the parity compare still holds.
let ra = rng(SEED)
for (let i = 0; i < N; i++) setSprite(nodeSprites[i]!, fields(ra))
let rb = rng(SEED)
for (let i = 0; i < N; i++) setSprite(recSprites[i]!, fields(rb))
}
let frame = 0
onFrame(() => {
frame++
if (frame === 3) {
bench()
groups()
}
if (frame === 6) {
pixelParity()
pickParity()
rectQuery()
console.log(failures.length === 0 ? "PARITY-OK" : `PARITY-FAIL: ${failures.join("; ")}`)
}
})
return (
)
}
render(() => )