refactor: make instance type metadata opaque

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-29 06:13:25 +00:00
co-authored by heaust
parent 092b319d75
commit 0e866596c0
11 changed files with 110 additions and 166 deletions
+1 -62
View File
@@ -59,30 +59,13 @@ impl MaterialKey {
}
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct InstanceType {
pub words: [u32; 16],
}
impl InstanceType {
pub const VISIBLE_MASK: u32 = 1;
pub const ZERO: Self = Self { words: [0; 16] };
pub const VISIBLE: Self = Self {
words: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
};
pub const fn is_visible(self) -> bool {
self.words[0] & Self::VISIBLE_MASK != 0
}
pub fn set_visible(&mut self, visible: bool) {
self.words[0] = (self.words[0] & !Self::VISIBLE_MASK) | visible as u32;
}
}
impl Default for InstanceType {
fn default() -> Self {
Self::VISIBLE
}
}
const _: [(); 64] = [(); std::mem::size_of::<InstanceType>()];
@@ -685,32 +668,6 @@ impl RenderData {
Ok(())
}
pub fn set_mesh_visible(
&mut self,
handle: MeshHandle,
visible: bool,
) -> Result<(), RenderDataError> {
if !self
.meshes
.slots
.contains(handle.slot(), handle.generation())
{
return Err(RenderDataError::InvalidMeshHandle);
}
let owned: Vec<u32> = self
.instances
.slots
.occupied()
.filter_map(|(slot, _)| (self.instances.mesh_handle(slot) == handle).then_some(slot))
.collect();
let next_revision = self.next_revision()?;
for slot in owned {
self.instances.instance_types[slot as usize].set_visible(visible);
}
self.revision = next_revision;
Ok(())
}
pub fn set_instance_type(
&mut self,
handle: InstanceHandle,
@@ -729,24 +686,6 @@ impl RenderData {
Ok(())
}
pub fn set_instance_visible(
&mut self,
handle: InstanceHandle,
visible: bool,
) -> Result<(), RenderDataError> {
if !self
.instances
.slots
.contains(handle.slot(), handle.generation())
{
return Err(RenderDataError::InvalidInstanceHandle);
}
let next_revision = self.next_revision()?;
self.instances.instance_types[handle.slot() as usize].set_visible(visible);
self.revision = next_revision;
Ok(())
}
pub fn set_instance_transform(
&mut self,
handle: InstanceHandle,
+29 -6
View File
@@ -104,7 +104,28 @@ fn default_instance_is_protected_and_preserves_its_type() {
data.destroy_instance(created.default_instance),
Err(RenderDataError::CannotDestroyDefaultInstance)
);
data.set_mesh_visible(created.mesh, false).unwrap();
let replacement = InstanceType {
words: [
0,
1,
2,
4,
8,
0x8000_0000,
u32::MAX,
17,
31,
63,
127,
255,
511,
1023,
2047,
4095,
],
};
data.set_instance_type(created.default_instance, replacement)
.unwrap();
assert_eq!(
data.mesh(created.mesh).unwrap().default_instance_type,
info().default_instance_type
@@ -113,14 +134,16 @@ fn default_instance_is_protected_and_preserves_its_type() {
data.instance(created.default_instance)
.unwrap()
.instance_type,
{
let mut expected = info().default_instance_type;
expected.set_visible(false);
expected
}
replacement
);
}
#[test]
fn instance_type_default_is_exactly_zero() {
assert_eq!(InstanceType::default(), InstanceType::ZERO);
assert_eq!(InstanceType::default().words, [0; 16]);
}
#[test]
fn stale_mesh_and_instance_handles_are_rejected_after_reuse() {
let mut data = data();
@@ -30,9 +30,6 @@ fn encode_scene<'a, T: Scene>(
pass.set_vertex_buffer(4, t.slice(..));
pass.set_index_buffer(i.slice(..), wgpu::IndexFormat::Uint32);
for draw in &gpu.draws {
if !draw.instance_type.is_visible() {
continue;
}
pass.set_pipeline(pipelines.get_pipeline(draw.pipeline));
if pipelines.requires_material(draw.pipeline) {
pass.set_bind_group(2, materials.group(draw.material), &[]);
+1 -3
View File
@@ -3,7 +3,7 @@ use std::mem::size_of;
use bytemuck::{Pod, Zeroable};
use crate::{
render_data::{InstanceType, MaterialKey, MeshHandle, PipelineKey},
render_data::{MaterialKey, MeshHandle, PipelineKey},
renderer::scene_frame::SceneFramePlan,
};
@@ -24,7 +24,6 @@ pub struct DrawItem {
pub indices: std::ops::Range<u32>,
pub base_vertex: i32,
pub instances: std::ops::Range<u32>,
pub instance_type: InstanceType,
}
#[repr(C)]
@@ -169,7 +168,6 @@ impl GpuScenePlan {
.ok_or("draw range overflow")?,
base_vertex,
instances: instance_index..instance_index + 1,
instance_type: occurrence.instance_type,
});
}
}
+16 -33
View File
@@ -1511,22 +1511,31 @@ impl<T: Scene + 'static> Renderer<T> {
let meshes = js_sys::Array::new();
for h in installed.meshes {
let item = js_sys::Object::new();
let view = self.render_data.mesh(h).unwrap();
js_sys::Reflect::set(
&item,
&"handle".into(),
&js_sys::Array::of2(&h.slot().into(), &h.generation().into()),
)
.unwrap();
let ty = self
.render_data
.mesh(h)
.unwrap()
.default_instance_type
.words;
js_sys::Reflect::set(
&item,
&"defaultInstance".into(),
&js_sys::Array::of2(
&view.default_instance.slot().into(),
&view.default_instance.generation().into(),
),
)
.unwrap();
js_sys::Reflect::set(
&item,
&"defaultType".into(),
&js_sys::Array::from_iter(ty.into_iter().map(JsValue::from)),
&js_sys::Array::from_iter(
view.default_instance_type
.words
.into_iter()
.map(JsValue::from),
),
)
.unwrap();
meshes.push(&item);
@@ -1534,19 +1543,6 @@ impl<T: Scene + 'static> Renderer<T> {
js_sys::Reflect::set(&result, &"meshes".into(), &meshes).unwrap();
Ok(result.into())
}
2 => {
self.render_data
.set_mesh_visible(
MeshHandle::from_parts(words[2], words[3]),
match words[4] {
0 => false,
1 => true,
_ => return Err("INVALID_VISIBILITY"),
},
)
.map_err(|e| render_data_error_code(&e))?;
Ok(JsValue::UNDEFINED)
}
3 => {
let mesh = MeshHandle::from_parts(words[2], words[3]);
let mut m = [[0.; 4]; 4];
@@ -1565,19 +1561,6 @@ impl<T: Scene + 'static> Renderer<T> {
.map_err(|e| render_data_error_code(&e))?;
Ok(js_sys::Array::of2(&h.slot().into(), &h.generation().into()).into())
}
4 => {
self.render_data
.set_instance_visible(
InstanceHandle::from_parts(words[2], words[3]),
match words[4] {
0 => false,
1 => true,
_ => return Err("INVALID_VISIBILITY"),
},
)
.map_err(|e| render_data_error_code(&e))?;
Ok(JsValue::UNDEFINED)
}
5 => {
let h = InstanceHandle::from_parts(words[2], words[3]);
let mut m = [[0.; 4]; 4];
+25 -23
View File
@@ -161,7 +161,7 @@ mod tests {
use super::*;
use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM};
fn mesh(data: &mut RenderData, visible: bool) -> crate::render_data::CreatedMesh {
fn mesh(data: &mut RenderData, instance_type: InstanceType) -> crate::render_data::CreatedMesh {
data.create_mesh(MeshCreateInfo {
positions: &[[0., 0., 0.], [2., 0., 0.], [0., 2., 0.]],
normals: &[[0., 0., 1.]; 3],
@@ -170,11 +170,7 @@ mod tests {
indices: &[0, 1, 2],
pipeline: PipelineKey::new(0),
material: crate::render_data::MaterialKey::DEFAULT,
default_instance_type: if visible {
InstanceType::VISIBLE
} else {
InstanceType::ZERO
},
default_instance_type: instance_type,
default_transform: IDENTITY_MODEL_TRANSFORM,
})
.unwrap()
@@ -186,10 +182,11 @@ mod tests {
let mut cache = SceneFrameCache::default();
let first = cache.get_or_build(&data).unwrap() as *const _;
assert_eq!(first, cache.get_or_build(&data).unwrap() as *const _);
let created = mesh(&mut data, true);
let created = mesh(&mut data, InstanceType::ZERO);
let second = cache.get_or_build(&data).unwrap() as *const _;
assert_ne!(first, second);
data.set_mesh_visible(created.mesh, false).unwrap();
data.set_instance_type(created.default_instance, InstanceType { words: [5; 16] })
.unwrap();
let third = cache.get_or_build(&data).unwrap() as *const _;
assert_ne!(second, third);
let mut moved = IDENTITY_MODEL_TRANSFORM;
@@ -200,10 +197,15 @@ mod tests {
}
#[test]
fn retains_hidden_entries_builds_adjacency_and_world_bounds() {
fn retains_all_entries_and_preserves_opaque_types() {
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
let hidden = mesh(&mut data, false);
let shown = mesh(&mut data, true);
let zero = mesh(&mut data, InstanceType::ZERO);
let marked = mesh(
&mut data,
InstanceType {
words: [0, u32::MAX, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9],
},
);
let mut translated = IDENTITY_MODEL_TRANSFORM;
translated[0][0] = 2.;
translated[1][1] = 3.;
@@ -211,14 +213,14 @@ mod tests {
translated[3][0] = 5.;
translated[3][1] = -2.;
let extra = data
.create_instance(hidden.mesh, translated, InstanceType::ZERO)
.create_instance(zero.mesh, translated, InstanceType::ZERO)
.unwrap();
let plan = SceneFramePlan::build(&data).unwrap();
assert_eq!((plan.meshes.len(), plan.occurrences.len()), (2, 3));
assert!(plan
.occurrences
.iter()
.any(|o| o.handle == hidden.default_instance && o.is_default));
.any(|o| o.handle == zero.default_instance && o.is_default));
let occurrence = plan.occurrences.iter().find(|o| o.handle == extra).unwrap();
assert_eq!(occurrence.world_aabb.min, [5., -2., 0.]);
assert_eq!(occurrence.world_aabb.max, [9., 4., 0.]);
@@ -228,10 +230,10 @@ mod tests {
.all(|&i| plan.occurrences[i].mesh_index == mesh_index));
}
assert_eq!(
shown.default_instance.slot(),
marked.default_instance.slot(),
plan.occurrences
.iter()
.find(|o| o.mesh == shown.mesh)
.find(|o| o.mesh == marked.mesh)
.unwrap()
.handle
.slot()
@@ -241,26 +243,26 @@ mod tests {
#[test]
fn slot_reuse_preserves_dense_order_adjacency_and_ownership() {
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
let a = mesh(&mut data, true);
let doomed = mesh(&mut data, true);
let c = mesh(&mut data, true);
let a = mesh(&mut data, InstanceType::ZERO);
let doomed = mesh(&mut data, InstanceType::ZERO);
let c = mesh(&mut data, InstanceType::ZERO);
let a_extra = data
.create_instance(a.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::VISIBLE)
.create_instance(a.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap();
let doomed_extra = data
.create_instance(doomed.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::VISIBLE)
.create_instance(doomed.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap();
let c_extra = data
.create_instance(c.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::VISIBLE)
.create_instance(c.mesh, IDENTITY_MODEL_TRANSFORM, InstanceType::ZERO)
.unwrap();
data.destroy_instance(a_extra).unwrap();
data.destroy_mesh(doomed.mesh).unwrap();
let replacement = mesh(&mut data, true);
let replacement = mesh(&mut data, InstanceType::ZERO);
let replacement_extra = data
.create_instance(
replacement.mesh,
IDENTITY_MODEL_TRANSFORM,
InstanceType::VISIBLE,
InstanceType::ZERO,
)
.unwrap();
assert_eq!(replacement.mesh.slot(), doomed.mesh.slot());
+4 -4
View File
@@ -1,11 +1,11 @@
export class DerivedBvh {
constructor() { this.count = 0; this.identity = new Uint32Array(); this.meshIdentity = new Uint32Array(); this.pickable = new Uint8Array(); this.bounds = new Float32Array(); this.nodeBounds = new Float32Array(); this.left = this.right = new Int32Array(); this.leafStart = this.leafCount = new Uint32Array(); this.leaves = new Uint32Array(); this.root = -1; this.rebuilds = 0; this.refits = 0; }
constructor() { this.count = 0; this.identity = new Uint32Array(); this.meshIdentity = new Uint32Array(); this.bounds = new Float32Array(); this.nodeBounds = new Float32Array(); this.left = this.right = new Int32Array(); this.leafStart = this.leafCount = new Uint32Array(); this.leaves = new Uint32Array(); this.root = -1; this.rebuilds = 0; this.refits = 0; }
update(snapshot) {
const s = snapshot.streams, n = snapshot.instanceCount;
let changed = n !== this.count;
if (!changed) for (let i = 0; i < n; i++) if (this.identity[i * 2] !== s.instanceSlot[i] || this.identity[i * 2 + 1] !== s.instanceGeneration[i] || this.meshIdentity[i * 2] !== s.instanceMeshSlot[i] || this.meshIdentity[i * 2 + 1] !== s.instanceMeshGeneration[i]) { changed = true; break; }
this.count = n; this.identity = new Uint32Array(n * 2); this.meshIdentity = new Uint32Array(n * 2); this.pickable = new Uint8Array(n); this.bounds = new Float32Array(n * 6);
for (let i = 0; i < n; i++) { this.identity.set([s.instanceSlot[i], s.instanceGeneration[i]], i * 2); this.meshIdentity.set([s.instanceMeshSlot[i], s.instanceMeshGeneration[i]], i * 2); this.pickable[i] = !!(s.instanceType[i * 16] & 1); this.bounds.set(s.instanceWorldMin.subarray(i * 3, i * 3 + 3), i * 6); this.bounds.set(s.instanceWorldMax.subarray(i * 3, i * 3 + 3), i * 6 + 3); }
this.count = n; this.identity = new Uint32Array(n * 2); this.meshIdentity = new Uint32Array(n * 2); this.bounds = new Float32Array(n * 6);
for (let i = 0; i < n; i++) { this.identity.set([s.instanceSlot[i], s.instanceGeneration[i]], i * 2); this.meshIdentity.set([s.instanceMeshSlot[i], s.instanceMeshGeneration[i]], i * 2); this.bounds.set(s.instanceWorldMin.subarray(i * 3, i * 3 + 3), i * 6); this.bounds.set(s.instanceWorldMax.subarray(i * 3, i * 3 + 3), i * 6 + 3); }
changed ? this.rebuild() : this.refit();
}
rebuild() {
@@ -19,7 +19,7 @@ export class DerivedBvh {
pick(origin, direction, maxDistance = Infinity, maxHits = 1) {
if (this.root < 0) return []; let magnitude = Math.hypot(...direction); const dir = direction.map(v => v / magnitude);
const intersect = (array, at) => { let lo = 0, hi = maxDistance; for (let a = 0; a < 3; a++) { const min = array[at + a], max = array[at + a + 3]; if (dir[a] === 0) { if (origin[a] < min || origin[a] > max) return Infinity; } else { let x = (min - origin[a]) / dir[a], y = (max - origin[a]) / dir[a]; if (x > y) [x, y] = [y, x]; lo = Math.max(lo, x); hi = Math.min(hi, y); if (lo > hi) return Infinity; } } return lo; };
const hits = [], stack = [this.root]; while (stack.length) { const n = stack.pop(); if (intersect(this.nodeBounds, n * 6) === Infinity) continue; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; if (!this.pickable[i]) continue; const distance = intersect(this.bounds, i * 6); if (distance !== Infinity) hits.push({slot: this.identity[i * 2], generation: this.identity[i * 2 + 1], distance}); } else stack.push(this.right[n], this.left[n]); }
const hits = [], stack = [this.root]; while (stack.length) { const n = stack.pop(); if (intersect(this.nodeBounds, n * 6) === Infinity) continue; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; const distance = intersect(this.bounds, i * 6); if (distance !== Infinity) hits.push({slot: this.identity[i * 2], generation: this.identity[i * 2 + 1], distance}); } else stack.push(this.right[n], this.left[n]); }
hits.sort((a, b) => a.distance - b.distance || a.slot - b.slot || a.generation - b.generation); return hits.slice(0, maxHits);
}
}
+12 -17
View File
@@ -1,8 +1,7 @@
import { SnapshotReader } from "./render-data-snapshot.js";
export const VISIBLE = 1;
const HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2;
const OP = { IMPORT_GLB: 1, MESH_FLAGS: 2, CREATE_INSTANCE: 3, INSTANCE_VISIBLE: 4, INSTANCE_TRANSFORM: 5, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, SET_INSTANCE_TYPE: 10 };
const OP = { IMPORT_GLB: 1, CREATE_INSTANCE: 3, INSTANCE_TRANSFORM: 5, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, SET_INSTANCE_TYPE: 10 };
const HANDLE_TOKEN = Symbol("renderer handle");
export class RendererError extends Error {
@@ -157,11 +156,11 @@ export class RendererClient {
return promise;
}
#mesh(handle, defaultType = Array(16).fill(0)) {
#mesh(handle, defaultInstanceHandle, defaultType = Array(16).fill(0)) {
return new Mesh(HANDLE_TOKEN,
visible => { validateVisible(visible); return this.#enqueue(OP.MESH_FLAGS, [...handle, visible ? 1 : 0]); },
async (transform, {type = defaultType, visible} = {}) => {
type = typeWords(type); if (visible !== undefined) { validateVisible(visible); type[0] = (type[0] & ~1) | Number(visible); }
this.#instance(defaultInstanceHandle),
async (transform, {type = defaultType} = {}) => {
type = typeWords(type);
const result = await this.#enqueue(OP.CREATE_INSTANCE, [...handle, ...floatWords(transform), ...type]);
return this.#instance(result);
});
@@ -169,7 +168,6 @@ export class RendererClient {
#instance(handle) {
return new Instance(HANDLE_TOKEN,
visible => { validateVisible(visible); return this.#enqueue(OP.INSTANCE_VISIBLE, [...handle, visible ? 1 : 0]); },
type => this.#enqueue(OP.SET_INSTANCE_TYPE, [...handle, ...typeWords(type)]),
transform => this.#enqueue(OP.INSTANCE_TRANSFORM, [...handle, ...floatWords(transform)]),
() => this.#enqueue(OP.DESTROY_INSTANCE, [...handle]));
@@ -185,7 +183,7 @@ export class RendererClient {
else throw new TypeError("GLB source must be URL, File, or ArrayBuffer");
if (this.#disposed) throw new RendererError("DISPOSED");
const result = await this.#withPayload(buffer, OP.IMPORT_GLB, [framing === "interior" ? 1 : 0]);
return result.meshes.map(item => this.#mesh(item.handle, item.defaultType));
return result.meshes.map(item => this.#mesh(item.handle, item.defaultInstance, item.defaultType));
}
@@ -260,7 +258,6 @@ function validateCompiledId(compiledId) {
if (!Array.isArray(compiledId) || compiledId.length !== 2 || compiledId.some(word => !Number.isInteger(word) || word < 0 || word > 0xffffffff)) throw new TypeError("compiledId must contain exactly two uint32 values");
}
function validateVisible(value) { if (typeof value !== "boolean") throw new TypeError("visible must be boolean"); }
function typeWords(words) { if (!words || words.length !== 16 || [...words].some(x => !Number.isInteger(x) || x < 0 || x > 0xffffffff)) throw new TypeError("type must contain exactly 16 uint32 values"); return Array.from(words, x => x >>> 0); }
function floatWords(matrix) {
@@ -269,27 +266,25 @@ function floatWords(matrix) {
}
class Mesh {
#setVisible; #createInstance;
constructor(token, setVisible, createInstance) {
#defaultInstance; #createInstance;
constructor(token, defaultInstance, createInstance) {
if (token !== HANDLE_TOKEN) throw new TypeError("Mesh cannot be constructed directly");
this.#setVisible = setVisible;
this.#defaultInstance = defaultInstance;
this.#createInstance = createInstance;
}
setVisible(visible) { return this.#setVisible(visible); }
get defaultInstance() { return this.#defaultInstance; }
createInstance(transform, options = {}) { return this.#createInstance(transform, options); }
}
class Instance {
#setVisible; #setType; #setTransform; #destroy; #dead = false;
constructor(token, setVisible, setType, setTransform, destroy) {
#setType; #setTransform; #destroy; #dead = false;
constructor(token, setType, setTransform, destroy) {
if (token !== HANDLE_TOKEN) throw new TypeError("Instance cannot be constructed directly");
this.#setVisible = setVisible;
this.#setType = setType;
this.#setTransform = setTransform;
this.#destroy = destroy;
}
#live() { if (this.#dead) throw new RendererError("STALE_HANDLE"); }
setVisible(visible) { this.#live(); return this.#setVisible(visible); }
setType(words) { this.#live(); return this.#setType(words); }
setTransform(transform) { this.#live(); return this.#setTransform(transform); }
async destroy() { this.#live(); await this.#destroy(); this.#dead = true; }
+1 -1
View File
@@ -20,7 +20,7 @@ test("all presets use current schemas, versions, and one frame output", () => {
.parameters.texture.sampleCount, "number");
});
test("presets classify visibility and material through type.words[0] predicates", () => {
test("presets classify demo-owned enable and material bits through type.words[0] predicates", () => {
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.type_words.inputs.value, { node: "mesh", socket: "type" }, name);
+13 -7
View File
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import * as rendererModule from "../static/renderer-client.js";
const { RendererClient, RendererError } = rendererModule;
const TYPE = [0,1,2,4,8,16,32,64,128,256,512,1024,2048,4096,0x80000000,0xffffffff];
class WorkerMock extends EventTarget {
messages=[]; transfers=[]; terminated=false;
@@ -22,26 +23,30 @@ async function imported(f) {
const loading=f.client.replaceSceneGlb(new ArrayBuffer(8));
f.worker.reply({type:"payload-ready",id:1});
await Promise.resolve();
f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[{handle:[7,3],defaultType:[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}});
f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[{handle:[7,3],defaultInstance:[8,5],defaultType:[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}});
return (await loading)[0];
}
test("replaceSceneGlb is opcode 1", async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8));f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.equal(new Int32Array(f.memory.buffer,64,40)[1],1);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});assert.deepEqual(await pending,[]);});
test("scene replacement carries the framing mode in opcode 1",async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"interior"});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.deepEqual([...new Int32Array(f.memory.buffer,64,40).slice(1,5)],[1,1,1,1]);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});await pending;await assert.rejects(f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"bad"}),TypeError)});
test("writes tagged fixed-slot protocol and resolves reply", async () => {
const f=fixture(); const mesh=await imported(f);
const pending=mesh.setVisible(true);
assert.equal(mesh.setVisible,undefined);
assert.equal(mesh.setType,undefined);
assert.equal(typeof mesh.defaultInstance.setType,"function");
const pending=mesh.defaultInstance.setType(TYPE);
const {memory,header,worker}=f;
assert.equal(Atomics.load(header,5),2);
const slot=new Int32Array(memory.buffer,64+160,40);
assert.deepEqual([...slot.slice(0,6)],[2,2,2,7,3,1]);
assert.deepEqual([...slot.slice(0,21)].map(x=>x>>>0),[2,10,2,8,5,...TYPE]);
worker.reply({type:"reply",request:2,ok:true,code:"OK"}); await pending;
});
test("maps stable errors and gates destroyed instances", async () => {
const f=fixture(), mesh=await imported(f); const {worker}=f;
const creating=mesh.createInstance(new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]),{visible:false});
const creating=mesh.createInstance(new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]),{type:TYPE});
worker.reply({type:"reply",request:2,ok:true,result:[4,2]}); const instance=await creating;
assert.equal(instance.setVisible,undefined);
const destroying=instance.destroy(); worker.reply({type:"reply",request:3,ok:true}); await destroying;
assert.throws(()=>instance.setVisible(true), error=>error instanceof RendererError&&error.code==="STALE_HANDLE");
assert.throws(()=>instance.setType(TYPE), error=>error instanceof RendererError&&error.code==="STALE_HANDLE");
});
test("rejects protocol mismatch", () => {
const {memory,worker}=fixture(); new Int32Array(memory.buffer)[1]=1;
@@ -49,7 +54,7 @@ test("rejects protocol mismatch", () => {
});
test("pending reply exists before ring publication", async () => {
const f=fixture(), mesh=await imported(f); const {worker}=f;
const pending=mesh.setVisible(true);
const pending=mesh.defaultInstance.setType(TYPE);
worker.reply({type:"reply",request:2,ok:true});
await pending;
});
@@ -61,7 +66,7 @@ test("profile snapshots have a dedicated getter", () => {
});
test("worker failures and dispose reject every pending operation", async () => {
const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f;
const a=mesh.setVisible(true), b=mesh.setVisible(false);
const a=mesh.defaultInstance.setType(TYPE), b=mesh.defaultInstance.setType([...TYPE].reverse());
worker.dispatchEvent(new Event("error"));
await assert.rejects(a,/WORKER_ERROR/); await assert.rejects(b,/WORKER_ERROR/);
client.dispose(); assert.equal(worker.terminated,true); assert.equal(bridge.freed,true);
@@ -75,6 +80,7 @@ test("import always releases staged payload when ring is full", async () => {
});
test("does not export handle constructors or internal mutation methods", () => {
const {client}=fixture();
assert.equal(rendererModule.VISIBLE,undefined);
assert.equal(rendererModule.Mesh,undefined);
assert.equal(rendererModule.Instance,undefined);
assert.equal(client._meshFlags,undefined);
+8 -7
View File
@@ -79,26 +79,26 @@ test("snapshot reader refreshes memory after pinning", () => {
}
});
function bvhSnapshot({ pickable = [1, 1], shifted = false } = {}) {
function bvhSnapshot({ metadata = [0, 0x80000000], shifted = false } = {}) {
const count = 2;
return { instanceCount: count, streams: {
instanceSlot: Uint32Array.from([5, 6]), instanceGeneration: Uint32Array.from([1, 1]),
instanceMeshSlot: Uint32Array.from([2, 2]), instanceMeshGeneration: Uint32Array.from([4, 4]),
instanceType: Uint32Array.from(pickable.flatMap(x => [x, ...Array(15).fill(0)])),
instanceType: Uint32Array.from(metadata.flatMap(x => [x, ...Array(15).fill(0)])),
instanceWorldMin: Float32Array.from(shifted ? [10, -1, -1, 4, -1, -1] : [2, -1, -1, 4, -1, -1]),
instanceWorldMax: Float32Array.from(shifted ? [12, 1, 1, 6, 1, 1] : [3, 1, 1, 6, 1, 1]),
}};
}
test("BVH preserves topology for visibility/refit and reports world distances", () => {
test("BVH ignores opaque instance metadata during refit and picking", () => {
const bvh = new DerivedBvh();
bvh.update(bvhSnapshot());
assert.equal(bvh.rebuilds, 1);
assert.equal(bvh.pick([0, 0, 0], [2, 0, 0], Infinity, 2)[0].distance, 2);
bvh.update(bvhSnapshot({ pickable: [0, 1], shifted: true }));
bvh.update(bvhSnapshot({ metadata: [0xffffffff, 0], shifted: true }));
assert.equal(bvh.rebuilds, 1);
assert.equal(bvh.refits, 1);
assert.deepEqual(bvh.pick([0, 0, 0], [1, 0, 0], Infinity, 2).map(hit => hit.slot), [6]);
assert.deepEqual(bvh.pick([0, 0, 0], [1, 0, 0], Infinity, 2).map(hit => hit.slot), [6, 5]);
});
class WorkerMock extends EventTarget {
@@ -108,7 +108,7 @@ class WorkerMock extends EventTarget {
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
}
test("renderer pick returns gated instances and exact epoch", async () => {
test("renderer pick returns instance metadata handles and exact epoch", async () => {
const scene = snapshotFixture();
const ring = 8192;
const ringHeader = new Int32Array(scene.memory.buffer, ring, 16);
@@ -124,7 +124,8 @@ test("renderer pick returns gated instances and exact epoch", async () => {
const result = await picking;
assert.equal(result.epoch, 1);
assert.equal(result.hits[0].distance, 2);
assert.equal(typeof result.hits[0].instance.setVisible, "function");
assert.equal(typeof result.hits[0].instance.setType, "function");
assert.equal(result.hits[0].instance.setVisible, undefined);
client.dispose();
assert.equal(bvhWorker.terminated, true);
});