From 0e866596c0f1f3c661f4c3f4eb5ae808b4672d96 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 29 Jul 2026 06:13:25 +0000 Subject: [PATCH] refactor: make instance type metadata opaque Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure --- renderer/src/render_data/mod.rs | 63 +-------------------- renderer/src/render_data/tests.rs | 35 ++++++++++-- renderer/src/renderer/executors/pipeline.rs | 3 - renderer/src/renderer/gpu_scene.rs | 4 +- renderer/src/renderer/mod.rs | 49 ++++++---------- renderer/src/renderer/scene_frame.rs | 48 ++++++++-------- static/bvh-core.js | 8 +-- static/renderer-client.js | 29 ++++------ tests/render-graph-presets.test.js | 2 +- tests/renderer-client.test.js | 20 ++++--- tests/snapshot-bvh.test.js | 15 ++--- 11 files changed, 110 insertions(+), 166 deletions(-) diff --git a/renderer/src/render_data/mod.rs b/renderer/src/render_data/mod.rs index 0ca4504..0ae47e0 100644 --- a/renderer/src/render_data/mod.rs +++ b/renderer/src/render_data/mod.rs @@ -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::()]; @@ -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 = 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, diff --git a/renderer/src/render_data/tests.rs b/renderer/src/render_data/tests.rs index ca88327..99fcd9b 100644 --- a/renderer/src/render_data/tests.rs +++ b/renderer/src/render_data/tests.rs @@ -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(); diff --git a/renderer/src/renderer/executors/pipeline.rs b/renderer/src/renderer/executors/pipeline.rs index 8dc58b2..6acfadb 100644 --- a/renderer/src/renderer/executors/pipeline.rs +++ b/renderer/src/renderer/executors/pipeline.rs @@ -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), &[]); diff --git a/renderer/src/renderer/gpu_scene.rs b/renderer/src/renderer/gpu_scene.rs index 8076fd3..ff56695 100644 --- a/renderer/src/renderer/gpu_scene.rs +++ b/renderer/src/renderer/gpu_scene.rs @@ -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, pub base_vertex: i32, pub instances: std::ops::Range, - 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, }); } } diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index b153882..14eef1e 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -1511,22 +1511,31 @@ impl Renderer { 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 Renderer { 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 Renderer { .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]; diff --git a/renderer/src/renderer/scene_frame.rs b/renderer/src/renderer/scene_frame.rs index 6db9d0a..4728b4a 100644 --- a/renderer/src/renderer/scene_frame.rs +++ b/renderer/src/renderer/scene_frame.rs @@ -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()); diff --git a/static/bvh-core.js b/static/bvh-core.js index ac8cfe7..4c320bd 100644 --- a/static/bvh-core.js +++ b/static/bvh-core.js @@ -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); } } diff --git a/static/renderer-client.js b/static/renderer-client.js index 4d34b4b..853ec08 100644 --- a/static/renderer-client.js +++ b/static/renderer-client.js @@ -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; } diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index 4bda2b5..6ea9e80 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -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); diff --git a/tests/renderer-client.test.js b/tests/renderer-client.test.js index 4fc4e9d..ada405b 100644 --- a/tests/renderer-client.test.js +++ b/tests/renderer-client.test.js @@ -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); diff --git a/tests/snapshot-bvh.test.js b/tests/snapshot-bvh.test.js index b4a9882..a4b2bc1 100644 --- a/tests/snapshot-bvh.test.js +++ b/tests/snapshot-bvh.test.js @@ -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); });