pub mod animation;
pub mod assets;
pub mod camera;
pub mod dynamic;
pub mod light;
pub mod material;
pub mod particles;
pub mod texture;
pub use assets::SceneAssets;
pub use particles::{drive_emitter, EmitterHandle};
use std::collections::HashMap;
use animation::AnimResolveMaps;
use anyhow::{anyhow, Result};
use awsm_renderer::animation::AnimationClipKey;
use awsm_renderer::cameras::CameraKey;
use awsm_renderer::decals::DecalKey;
use awsm_renderer::lights::LightKey;
#[cfg(feature = "lod")]
use awsm_renderer::lod::{LodChain, LodLevel};
use awsm_renderer::materials::unlit::UnlitMaterial;
use awsm_renderer::materials::{Material, MaterialAlphaMode, MaterialKey};
use awsm_renderer::meshes::MeshKey;
use awsm_renderer::pipeline_scheduler::CompileProgress;
use awsm_renderer::raw_mesh::RawMeshData;
use awsm_renderer::render_passes::lines::LineKey;
use awsm_renderer::transforms::{Transform, TransformKey};
use awsm_renderer::{AwsmRenderer, LoadPhase};
use awsm_renderer_core::texture::mipmap::MipmapTextureKind;
use awsm_renderer_gltf::loader::GltfLoader;
use awsm_renderer_gltf::{AwsmRendererGltfExt, GltfMaterialSource, PopulateGltfOpts};
use awsm_renderer_scene::{
mesh_glb_filename, AssetId, AssetSource, CameraConfig, CurveDef, DecalConfig, EditorNode,
InstancesAlongCurveDef, LightConfig, LineDef, MaterialInstance, MaterialShading, NodeId,
NodeKind, ParticleEmitterDef, RuntimeMesh, Scene, SpriteDef, Trs, ASSETS_DIR,
};
use glam::{Mat4, Quat, Vec3, Vec4};
#[derive(Clone, Debug, Default)]
pub struct NodeHandles {
pub transform: TransformKey,
pub meshes: Vec<MeshKey>,
pub light: Option<LightKey>,
pub camera: Option<CameraKey>,
pub camera_config: Option<CameraConfig>,
pub line: Option<LineKey>,
pub decal: Option<DecalKey>,
pub emitter: Option<EmitterHandle>,
}
#[derive(Default, Debug)]
pub struct LoadedScene {
pub nodes: HashMap<NodeId, NodeHandles>,
pub prefabs: HashMap<NodeId, PrefabTemplate>,
pub meshes: Vec<MeshKey>,
pub lights: Vec<LightKey>,
pub clips: Vec<AnimationClipKey>,
pub lines: Vec<LineKey>,
pub decals: Vec<DecalKey>,
pub transforms: Vec<TransformKey>,
}
#[derive(Debug)]
pub struct PrefabTemplate {
root: NodeId,
nodes: Vec<PrefabNode>,
}
#[derive(Debug)]
struct PrefabNode {
id: NodeId,
local: Trs,
parent: Option<NodeId>,
template_meshes: Vec<MeshKey>,
replay: PrefabReplay,
}
#[derive(Debug, Clone)]
enum PrefabReplay {
None,
ParticleEmitter(ParticleEmitterDef),
Light(LightConfig),
Camera(CameraConfig),
Line(LineDef),
Decal { texture_index: u32, alpha: f32 },
InstancesAlongCurve {
transforms: Vec<Transform>,
source_node: NodeId,
per_instance_colors: Vec<[f32; 4]>,
},
}
fn replay_prefab_node(
renderer: &mut AwsmRenderer,
replay: &PrefabReplay,
tk: TransformKey,
world: Mat4,
handles: &mut NodeHandles,
) {
match replay {
PrefabReplay::None => {}
PrefabReplay::InstancesAlongCurve { .. } => {}
PrefabReplay::ParticleEmitter(def) => {
match particles::build_emitter(renderer, def, tk, world) {
Ok(handle) => handles.emitter = Some(handle),
Err(err) => {
tracing::warn!("prefab replay: ParticleEmitter build failed: {err}")
}
}
}
PrefabReplay::Light(cfg) => {
let pos = world.w_axis.truncate();
let dir = world.transform_vector3(Vec3::NEG_Z).normalize_or_zero();
let lt = light::light_from_config(cfg, pos, dir);
let shadow = light::light_shadow_params_from_config(cfg.shadow());
if let Ok(k) = renderer.insert_light(lt, Some(shadow)) {
renderer.lights.bind_transform(k, tk);
handles.light = Some(k);
}
}
PrefabReplay::Camera(cfg) => {
let ck = renderer
.cameras
.insert(camera::camera_params_from_config(cfg));
handles.camera = Some(ck);
handles.camera_config = Some(cfg.clone());
}
PrefabReplay::Line(def) => {
if def.points.len() < 2 {
return;
}
let positions: Vec<Vec3> = def
.points
.iter()
.map(|p| world.transform_point3(Vec3::from_array(p.pos)))
.collect();
let colors: Vec<Vec4> = def
.points
.iter()
.map(|p| Vec4::from_array(p.color))
.collect();
match renderer.add_line_strip(&positions, &colors, def.width_px, def.depth_test_always)
{
Ok(Some(k)) => handles.line = Some(k),
Ok(None) => {}
Err(err) => tracing::warn!("prefab instantiate: add_line_strip failed: {err}"),
}
}
PrefabReplay::Decal {
texture_index,
alpha,
} => {
use awsm_renderer::decals::AwsmDecalError;
match renderer.insert_decal(world, *texture_index, *alpha) {
Ok(k) => handles.decal = Some(k),
Err(AwsmDecalError::FeatureNotEnabled) => {}
Err(err) => tracing::warn!("prefab instantiate: insert_decal failed: {err:?}"),
}
}
}
}
impl PrefabTemplate {
pub fn root_id(&self) -> NodeId {
self.root
}
pub fn node_ids(&self) -> impl Iterator<Item = NodeId> + '_ {
self.nodes.iter().map(|n| n.id)
}
pub fn instantiate(
&self,
renderer: &mut AwsmRenderer,
world_trs: Trs,
) -> Result<PrefabInstance> {
let mut tk_for: HashMap<NodeId, TransformKey> = HashMap::with_capacity(self.nodes.len());
let mut world_for: HashMap<NodeId, Mat4> = HashMap::with_capacity(self.nodes.len());
let mut nodes: HashMap<NodeId, NodeHandles> = HashMap::with_capacity(self.nodes.len());
let mut root_tk: Option<TransformKey> = None;
for pn in &self.nodes {
let (local, parent_tk) = match pn.parent {
None => (trs_to_transform(&world_trs), None),
Some(parent_id) => (trs_to_transform(&pn.local), tk_for.get(&parent_id).copied()),
};
let world = match pn.parent {
None => local.to_matrix(),
Some(parent_id) => {
world_for.get(&parent_id).copied().unwrap_or(Mat4::IDENTITY) * local.to_matrix()
}
};
let tk = renderer.transforms.insert(local, parent_tk);
tk_for.insert(pn.id, tk);
world_for.insert(pn.id, world);
if pn.parent.is_none() {
root_tk = Some(tk);
}
let mut mesh_keys = Vec::with_capacity(pn.template_meshes.len());
for &template_key in &pn.template_meshes {
let new_key = renderer.duplicate_mesh_with_transform(template_key, tk)?;
renderer.set_mesh_hidden(new_key, false)?;
mesh_keys.push(new_key);
}
let mut handles = NodeHandles {
transform: tk,
meshes: mesh_keys,
..Default::default()
};
replay_prefab_node(renderer, &pn.replay, tk, world, &mut handles);
nodes.insert(pn.id, handles);
}
for pn in &self.nodes {
let PrefabReplay::InstancesAlongCurve {
transforms,
source_node,
per_instance_colors,
} = &pn.replay
else {
continue;
};
if transforms.is_empty() {
continue;
}
let Some(&source_mesh) = nodes.get(source_node).and_then(|h| h.meshes.first()) else {
tracing::warn!(
"prefab: InstancesAlongCurve source {:?} has no duplicated mesh in this \
instance — instancing skipped",
source_node
);
continue;
};
let transform_key = match renderer.meshes.get(source_mesh) {
Ok(m) => m.transform_key,
Err(_) => continue,
};
if let Err(err) = renderer.enable_mesh_instancing_opaque(source_mesh, transforms) {
tracing::warn!("prefab: enable_mesh_instancing_opaque failed: {err}");
continue;
}
if !per_instance_colors.is_empty() {
let attrs: Vec<awsm_renderer::instances::InstanceAttr> =
expand_instance_colors(per_instance_colors, transforms.len())
.into_iter()
.map(|c| {
awsm_renderer::instances::InstanceAttr::from_rgba_alpha_size(
c, 1.0, 1.0,
)
})
.collect();
if let Err(err) = renderer.set_mesh_instance_attrs(transform_key, &attrs) {
tracing::warn!("prefab: curve per-instance colours failed: {err}");
}
}
}
let root = root_tk.ok_or_else(|| anyhow!("prefab template has no root node"))?;
Ok(PrefabInstance { root, nodes })
}
}
#[derive(Clone, Debug)]
pub struct PrefabInstance {
pub root: TransformKey,
pub nodes: HashMap<NodeId, NodeHandles>,
}
impl LoadedScene {
pub fn teardown(self, renderer: &mut AwsmRenderer) {
for mesh in self.meshes {
renderer.remove_mesh(mesh);
}
for light in self.lights {
renderer.remove_light(light);
}
for clip in self.clips {
renderer.animations.remove_clip(clip);
}
for line in self.lines {
renderer.remove_line(line);
}
for decal in self.decals {
renderer.remove_decal(decal);
}
for tk in self.transforms {
renderer.transforms.remove(tk);
}
}
}
impl PrefabInstance {
pub fn teardown(self, renderer: &mut AwsmRenderer) {
for handles in self.nodes.values() {
for &mesh in &handles.meshes {
renderer.remove_mesh(mesh);
}
if let Some(light) = handles.light {
renderer.remove_light(light);
}
if let Some(line) = handles.line {
renderer.remove_line(line);
}
if let Some(decal) = handles.decal {
renderer.remove_decal(decal);
}
if let Some(emitter) = &handles.emitter {
renderer.remove_mesh(emitter.mesh);
renderer.transforms.remove(emitter.instance_transform);
}
}
for handles in self.nodes.values() {
renderer.transforms.remove(handles.transform);
}
}
}
pub fn set_node_visible(renderer: &mut AwsmRenderer, handles: &NodeHandles, visible: bool) {
for &k in &handles.meshes {
let _ = renderer.set_mesh_hidden(k, !visible);
}
}
pub async fn populate_awsm_scene(
renderer: &mut AwsmRenderer,
scene: &Scene,
assets: &HashMap<String, Vec<u8>>,
on_phase: impl FnMut(LoadPhase),
) -> Result<LoadedScene> {
load_scene_for_player(renderer, scene, assets, on_phase).await
}
pub async fn load_scene_for_player(
renderer: &mut AwsmRenderer,
scene: &Scene,
assets: &impl SceneAssets,
mut on_phase: impl FnMut(LoadPhase),
) -> Result<LoadedScene> {
let custom = dynamic::register_custom_materials(renderer, scene, assets).await;
let placeholder = insert_placeholder_material(renderer);
let mut maps = AnimResolveMaps::default();
let renderables = collect_renderables(&scene.nodes);
let total = renderables.len();
for (i, (id, material)) in renderables.iter().enumerate() {
on_phase(LoadPhase::BuildingMaterials { done: i, total });
let key = resolve_material(renderer, material.as_ref(), placeholder, assets, &custom).await;
maps.node_materials.insert(*id, key);
if let Some(inst) = material.as_ref() {
if custom.contains_key(&inst.asset) {
maps.custom_materials.entry(inst.asset).or_insert(key);
}
}
}
on_phase(LoadPhase::BuildingMaterials { done: total, total });
maps.custom_shaders = custom;
let mut loaded = LoadedScene::default();
let mut uploaded = 0usize;
for node in &scene.nodes {
materialize(
renderer,
scene,
node,
None,
glam::Mat4::IDENTITY,
true,
assets,
&mut maps,
placeholder,
&mut on_phase,
&mut uploaded,
total,
&mut loaded,
)
.await?;
}
for (&node_id, &tk) in &maps.transforms {
loaded.transforms.push(tk);
loaded.nodes.insert(
node_id,
NodeHandles {
transform: tk,
meshes: maps.node_meshes.get(&node_id).cloned().unwrap_or_default(),
light: maps.lights.get(&node_id).copied(),
camera: maps.cameras.get(&node_id).copied(),
camera_config: maps.camera_configs.get(&node_id).cloned(),
line: maps.lines.get(&node_id).copied(),
decal: maps.decals.get(&node_id).copied(),
emitter: maps.emitters.get(&node_id).cloned(),
},
);
}
loaded.lines.extend(maps.lines.values().copied());
loaded.decals.extend(maps.decals.values().copied());
loaded.clips = animation::load_animations(renderer, scene, &maps);
renderer
.commit_load(|stats| {
use awsm_renderer::loading::LoadPhase as P;
match stats.phase {
P::UploadingGeometry => on_phase(LoadPhase::UploadingMeshes {
done: stats.geometry_uploaded,
total: stats.geometry_total,
}),
P::FinalizingTextures => on_phase(LoadPhase::UploadingTextures),
P::Compiling => on_phase(LoadPhase::CompilingPipelines(CompileProgress {
materials_pending: stats.pipelines_pending,
materials_ready: stats.pipelines_ready,
materials_failed: stats.pipelines_failed,
in_flight_subcompiles: stats.in_flight_subcompiles,
})),
P::Idle | P::Ready => {}
}
})
.await?;
Ok(loaded)
}
fn collect_renderables(nodes: &[EditorNode]) -> Vec<(NodeId, &Option<MaterialInstance>)> {
let mut out = Vec::new();
fn walk<'a>(nodes: &'a [EditorNode], out: &mut Vec<(NodeId, &'a Option<MaterialInstance>)>) {
for n in nodes {
match &n.kind {
NodeKind::Mesh { material, .. } | NodeKind::SkinnedMesh { material, .. } => {
out.push((n.id, material));
}
_ => {}
}
walk(&n.children, out);
}
}
walk(nodes, &mut out);
out
}
#[allow(clippy::too_many_arguments)]
async fn materialize(
renderer: &mut AwsmRenderer,
scene: &Scene,
node: &EditorNode,
parent: Option<TransformKey>,
parent_world: glam::Mat4,
parent_effective_visible: bool,
assets: &impl SceneAssets,
maps: &mut AnimResolveMaps,
placeholder: MaterialKey,
on_phase: &mut dyn FnMut(LoadPhase),
uploaded: &mut usize,
total: usize,
loaded: &mut LoadedScene,
) -> Result<()> {
let effective_visible = parent_effective_visible && node.visible;
if node.prefab {
let tmpl = capture_prefab(
renderer,
scene,
node,
None,
assets,
&maps.node_materials,
placeholder,
loaded,
)
.await?;
loaded.prefabs.insert(node.id, tmpl);
return Ok(());
}
let local = trs_to_transform(&node.transform);
let node_world = parent_world * local.to_matrix();
let tk = renderer.transforms.insert(local, parent);
maps.transforms.insert(node.id, tk);
let mat = maps
.node_materials
.get(&node.id)
.copied()
.unwrap_or(placeholder);
match &node.kind {
NodeKind::Mesh { mesh, .. } => {
if let Some(entry) = scene.assets.get(mesh.0) {
match &entry.source {
AssetSource::Mesh(RuntimeMesh::Primitive(shape)) => {
let md = awsm_renderer_meshgen::primitive_mesh(shape);
let key = renderer.add_raw_mesh(mesh_data_to_raw(md), tk, mat)?;
maps.meshes.entry(node.id).or_insert(key);
maps.node_meshes.entry(node.id).or_default().push(key);
loaded.meshes.push(key);
}
AssetSource::Mesh(RuntimeMesh::Glb) => {
let cluster_key =
load_cluster_lod(renderer, assets, &mesh.0.to_string(), tk, mat)
.await?;
if let Some(ckey) = cluster_key {
maps.meshes.entry(node.id).or_insert(ckey);
maps.node_meshes.entry(node.id).or_default().push(ckey);
loaded.meshes.push(ckey);
} else {
let (keys, _) = load_glb_under(
renderer,
assets,
&mesh_glb_filename(mesh.0),
Some(tk),
mat,
)
.await?;
if let Some(&first) = keys.first() {
maps.meshes.entry(node.id).or_insert(first);
if node_lod_enabled(&node.kind) {
load_static_lod_chain(
renderer,
assets,
&mesh.0.to_string(),
first,
tk,
mat,
)
.await?;
}
}
maps.node_meshes
.entry(node.id)
.or_default()
.extend(keys.iter().copied());
loaded.meshes.extend(keys);
}
}
_ => {}
}
}
*uploaded += 1;
on_phase(LoadPhase::UploadingMeshes {
done: *uploaded,
total,
});
}
#[cfg(feature = "lod")]
NodeKind::ClusterMesh { cluster, .. } => {
let id = cluster.source.0.to_string();
let path = format!(
"{ASSETS_DIR}/{}",
awsm_renderer_lod_bake::cluster_mesh_filename(&id)
);
if let Ok(bytes) = assets.fetch(&path).await {
match serde_json::from_slice::<awsm_renderer_lod_bake::ClusterMesh>(&bytes) {
Ok(cm) => {
if let Some(key) =
materialize_cluster_mesh(renderer, &cm, &id, tk, mat).await?
{
maps.meshes.entry(node.id).or_insert(key);
maps.node_meshes.entry(node.id).or_default().push(key);
loaded.meshes.push(key);
}
}
Err(e) => tracing::warn!("cluster mesh `{path}`: unreadable: {e}"),
}
} else {
tracing::warn!("cluster mesh asset `{path}` not found");
}
}
#[cfg(not(feature = "lod"))]
NodeKind::ClusterMesh { .. } => {}
NodeKind::SkinnedMesh { skin, .. } => {
let (keys, node_index_transforms) =
load_glb_under(renderer, assets, &mesh_glb_filename(skin.source), None, mat)
.await?;
if let Some(&first) = keys.first() {
maps.meshes.entry(node.id).or_insert(first);
}
maps.node_meshes
.entry(node.id)
.or_default()
.extend(keys.iter().copied());
for j in &skin.joints {
if let Some(&tk) = node_index_transforms.get(&(j.index as usize)) {
maps.skin_joints.insert(j.node, tk);
}
}
if let (Some(&base_key), true) = (keys.first(), node_lod_enabled(&node.kind)) {
load_skinned_lod_chain(
renderer,
assets,
&skin.source.to_string(),
base_key,
&node_index_transforms,
mat,
)
.await?;
}
loaded.meshes.extend(keys);
*uploaded += 1;
on_phase(LoadPhase::UploadingMeshes {
done: *uploaded,
total,
});
}
NodeKind::Light(cfg) => {
if effective_visible {
let pos = Vec3::from_array(node.transform.translation);
let dir =
(Quat::from_array(node.transform.rotation) * Vec3::NEG_Z).normalize_or_zero();
let lt = light::light_from_config(cfg, pos, dir);
let shadow = light::light_shadow_params_from_config(cfg.shadow());
let casts = shadow.cast;
if let Ok(k) = renderer.insert_light(lt, Some(shadow)) {
renderer.lights.bind_transform(k, tk);
maps.lights.insert(node.id, k);
loaded.lights.push(k);
}
if casts {
renderer.ensure_shadow_pipelines_compiled().await?;
}
}
}
NodeKind::Camera(cfg) => {
let ck = renderer
.cameras
.insert(camera::camera_params_from_config(cfg));
maps.cameras.insert(node.id, ck);
maps.camera_configs.insert(node.id, cfg.clone());
}
NodeKind::Line(def) => {
if effective_visible {
materialize_line(renderer, def, node.id, node_world, maps).await?;
}
}
NodeKind::Sprite(def) => {
materialize_sprite(renderer, assets, def, node.id, tk, maps, loaded).await?;
}
NodeKind::Decal(cfg) => {
if effective_visible {
materialize_decal(renderer, assets, cfg, node.id, node_world, maps).await?;
}
}
NodeKind::InstancesAlongCurve(def) => {
materialize_instances_along_curve(renderer, scene, def, maps)?;
}
NodeKind::Curve(_) => {}
NodeKind::ParticleEmitter(def) => {
if effective_visible {
match particles::build_emitter(renderer, def, tk, node_world) {
Ok(handle) => {
loaded.meshes.push(handle.mesh);
loaded.transforms.push(handle.instance_transform);
maps.emitters.insert(node.id, handle);
}
Err(err) => {
tracing::warn!("scene-loader: ParticleEmitter build failed: {err}");
}
}
}
}
NodeKind::Group | NodeKind::Collider(_) => {}
}
if !effective_visible {
if let Some(keys) = maps.node_meshes.get(&node.id) {
for &k in keys {
let _ = renderer.set_mesh_hidden(k, true);
}
}
}
for child in &node.children {
Box::pin(materialize(
renderer,
scene,
child,
Some(tk),
node_world,
effective_visible,
assets,
maps,
placeholder,
on_phase,
uploaded,
total,
loaded,
))
.await?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn capture_prefab(
renderer: &mut AwsmRenderer,
scene: &Scene,
node: &EditorNode,
parent: Option<NodeId>,
assets: &impl SceneAssets,
node_materials: &HashMap<NodeId, MaterialKey>,
placeholder: MaterialKey,
loaded: &mut LoadedScene,
) -> Result<PrefabTemplate> {
debug_assert!(
parent.is_none(),
"prefab root capture starts at the subtree root"
);
let layout = prefab_subtree_layout(node);
let scratch = renderer.transforms.insert(Transform::default(), None);
let mut nodes = Vec::with_capacity(layout.len());
for step in &layout {
let n = step.node;
if step.nested_prefab {
let tmpl = Box::pin(capture_prefab(
renderer,
scene,
n,
None,
assets,
node_materials,
placeholder,
loaded,
))
.await?;
loaded.prefabs.insert(n.id, tmpl);
continue;
}
let mat = node_materials.get(&n.id).copied().unwrap_or(placeholder);
let template_meshes =
build_node_meshes(renderer, scene, n, scratch, mat, assets, true).await?;
let replay = match &n.kind {
NodeKind::Light(cfg) => PrefabReplay::Light(cfg.clone()),
NodeKind::Camera(cfg) => PrefabReplay::Camera(cfg.clone()),
NodeKind::Line(def) => PrefabReplay::Line(def.clone()),
NodeKind::Decal(cfg) => PrefabReplay::Decal {
texture_index: resolve_decal_texture_index(renderer, assets, cfg).await,
alpha: cfg.alpha,
},
NodeKind::ParticleEmitter(def) => PrefabReplay::ParticleEmitter(def.clone()),
NodeKind::InstancesAlongCurve(def) => match find_curve(&scene.nodes, def.curve_node) {
Some(curve) => PrefabReplay::InstancesAlongCurve {
transforms: curve_instance_transforms(curve, def),
source_node: def.source_node,
per_instance_colors: def.per_instance_colors.clone(),
},
None => PrefabReplay::None,
},
_ => PrefabReplay::None,
};
nodes.push(PrefabNode {
id: n.id,
local: n.transform,
parent: step.parent,
template_meshes,
replay,
});
}
for pn in &nodes {
loaded.meshes.extend(pn.template_meshes.iter().copied());
}
loaded.transforms.push(scratch);
Ok(PrefabTemplate {
root: node.id,
nodes,
})
}
struct PrefabLayoutStep<'a> {
node: &'a EditorNode,
parent: Option<NodeId>,
nested_prefab: bool,
}
fn prefab_subtree_layout(root: &EditorNode) -> Vec<PrefabLayoutStep<'_>> {
fn walk<'a>(
node: &'a EditorNode,
parent: Option<NodeId>,
is_root: bool,
out: &mut Vec<PrefabLayoutStep<'a>>,
) {
if !is_root && node.prefab {
out.push(PrefabLayoutStep {
node,
parent,
nested_prefab: true,
});
return;
}
out.push(PrefabLayoutStep {
node,
parent,
nested_prefab: false,
});
for child in &node.children {
walk(child, Some(node.id), false, out);
}
}
let mut out = Vec::new();
walk(root, None, true, &mut out);
out
}
async fn build_node_meshes(
renderer: &mut AwsmRenderer,
scene: &Scene,
node: &EditorNode,
tk: TransformKey,
mat: MaterialKey,
assets: &impl SceneAssets,
hidden: bool,
) -> Result<Vec<MeshKey>> {
let mut keys: Vec<MeshKey> = Vec::new();
match &node.kind {
NodeKind::Mesh { mesh, .. } => {
if let Some(entry) = scene.assets.get(mesh.0) {
match &entry.source {
AssetSource::Mesh(RuntimeMesh::Primitive(shape)) => {
let md = awsm_renderer_meshgen::primitive_mesh(shape);
let key = renderer.add_raw_mesh(mesh_data_to_raw(md), tk, mat)?;
keys.push(key);
}
AssetSource::Mesh(RuntimeMesh::Glb) => {
let (glb_keys, _) = load_glb_under(
renderer,
assets,
&mesh_glb_filename(mesh.0),
Some(tk),
mat,
)
.await?;
keys.extend(glb_keys);
}
_ => {}
}
}
}
NodeKind::SkinnedMesh { skin, .. } => {
let (glb_keys, _) =
load_glb_under(renderer, assets, &mesh_glb_filename(skin.source), None, mat)
.await?;
keys.extend(glb_keys);
}
NodeKind::Sprite(def) => {
let key = build_sprite_mesh(renderer, assets, def, tk).await?;
keys.push(key);
}
_ => {}
}
if hidden {
for &k in &keys {
renderer.set_mesh_hidden(k, true)?;
}
}
Ok(keys)
}
async fn materialize_line(
renderer: &mut AwsmRenderer,
def: &LineDef,
node_id: NodeId,
node_world: Mat4,
maps: &mut AnimResolveMaps,
) -> Result<()> {
if def.points.len() < 2 {
return Ok(());
}
let positions: Vec<Vec3> = def
.points
.iter()
.map(|p| node_world.transform_point3(Vec3::from_array(p.pos)))
.collect();
let colors: Vec<Vec4> = def
.points
.iter()
.map(|p| Vec4::from_array(p.color))
.collect();
if let Some(key) =
renderer.add_line_strip(&positions, &colors, def.width_px, def.depth_test_always)?
{
maps.lines.insert(node_id, key);
renderer.ensure_line_pipelines_compiled().await?;
}
Ok(())
}
async fn materialize_sprite(
renderer: &mut AwsmRenderer,
assets: &impl SceneAssets,
def: &SpriteDef,
node_id: NodeId,
tk: TransformKey,
maps: &mut AnimResolveMaps,
loaded: &mut LoadedScene,
) -> Result<()> {
let key = build_sprite_mesh(renderer, assets, def, tk).await?;
maps.meshes.entry(node_id).or_insert(key);
maps.node_meshes.entry(node_id).or_default().push(key);
loaded.meshes.push(key);
Ok(())
}
async fn build_sprite_mesh(
renderer: &mut AwsmRenderer,
assets: &impl SceneAssets,
def: &SpriteDef,
tk: TransformKey,
) -> Result<MeshKey> {
use awsm_renderer::materials::flipbook::{FlipBookMaterial, FlipBookMode};
use awsm_renderer::materials::unlit::UnlitMaterial;
use awsm_renderer::meshes::mesh::BillboardMode as RBillboard;
use awsm_renderer_core::texture::mipmap::MipmapTextureKind;
use awsm_renderer_scene::{BillboardMode, FlipBookModeDef, SpriteAlphaMode};
let alpha = match def.alpha_mode {
SpriteAlphaMode::Opaque => MaterialAlphaMode::Opaque,
SpriteAlphaMode::Mask { cutoff_x1000 } => MaterialAlphaMode::Mask {
cutoff: cutoff_x1000 as f32 / 1000.0,
},
SpriteAlphaMode::Blend => MaterialAlphaMode::Blend,
};
let tex = match &def.texture {
Some(t) => {
texture::load_texture(renderer, assets, t, true, MipmapTextureKind::Albedo).await
}
None => None,
};
let material = match &def.flipbook {
Some(fb) => {
let mut m = FlipBookMaterial::new(alpha, true);
m.tint = def.tint;
m.cols = fb.cols.max(1);
m.rows = fb.rows.max(1);
m.frame_count = fb.frame_count.max(1);
m.fps = fb.fps;
m.time_offset = fb.time_offset;
m.mode = match fb.mode {
FlipBookModeDef::Loop => FlipBookMode::Loop,
FlipBookModeDef::PingPong => FlipBookMode::PingPong,
FlipBookModeDef::Clamp => FlipBookMode::Clamp,
FlipBookModeDef::Once => FlipBookMode::Once,
};
m.flip_y = fb.flip_y;
m.atlas_tex = tex;
Material::FlipBook(Box::new(m))
}
None => {
let mut m = UnlitMaterial::new(alpha, true);
m.base_color_factor = def.tint;
m.base_color_tex = tex;
Material::Unlit(m)
}
};
let mat = renderer.materials.insert(
material,
&renderer.textures,
&renderer.dynamic_materials,
&renderer.extras_pool,
);
let md = awsm_renderer_meshgen::sprite_quad(def.size[0], def.size[1]);
let key = renderer.add_raw_mesh(mesh_data_to_raw(md), tk, mat)?;
let rbillboard = match def.billboard {
BillboardMode::None => RBillboard::None,
BillboardMode::YAxis => RBillboard::YAxis,
BillboardMode::Full => RBillboard::Full,
};
if !matches!(rbillboard, RBillboard::None) {
renderer.set_mesh_billboard_mode(key, rbillboard)?;
}
Ok(key)
}
async fn materialize_decal(
renderer: &mut AwsmRenderer,
assets: &impl SceneAssets,
cfg: &DecalConfig,
node_id: NodeId,
node_world: Mat4,
maps: &mut AnimResolveMaps,
) -> Result<()> {
use awsm_renderer::decals::AwsmDecalError;
let texture_index = resolve_decal_texture_index(renderer, assets, cfg).await;
match renderer.insert_decal(node_world, texture_index, cfg.alpha) {
Ok(key) => {
maps.decals.insert(node_id, key);
}
Err(AwsmDecalError::FeatureNotEnabled) => warn_decal_feature_off(),
Err(err) => tracing::warn!("scene-loader: insert_decal failed: {err:?}"),
}
Ok(())
}
async fn resolve_decal_texture_index(
renderer: &mut AwsmRenderer,
assets: &impl SceneAssets,
cfg: &DecalConfig,
) -> u32 {
let stride = awsm_renderer::decals::decal_texture_index_stride(&renderer.gpu);
match &cfg.texture {
Some(t) => {
match texture::load_texture(renderer, assets, t, true, MipmapTextureKind::Albedo).await
{
Some(mt) => renderer
.textures
.get_entry(mt.key)
.map(|e| (e.array_index as u32) * stride + e.layer_index as u32)
.unwrap_or(0),
None => 0,
}
}
None => 0,
}
}
fn materialize_instances_along_curve(
renderer: &mut AwsmRenderer,
scene: &Scene,
def: &InstancesAlongCurveDef,
maps: &mut AnimResolveMaps,
) -> Result<()> {
let Some(curve) = find_curve(&scene.nodes, def.curve_node) else {
tracing::warn!(
"scene-loader: InstancesAlongCurve references missing/non-curve node {:?}",
def.curve_node
);
return Ok(());
};
let Some(&source_mesh) = maps.meshes.get(&def.source_node) else {
tracing::warn!(
"scene-loader: InstancesAlongCurve source node {:?} has no materialized mesh \
(must precede the instances node) — skipped",
def.source_node
);
return Ok(());
};
let transforms = curve_instance_transforms(curve, def);
if transforms.is_empty() {
return Ok(());
}
let transform_key = renderer.meshes.get(source_mesh)?.transform_key;
if let Err(err) = renderer.enable_mesh_instancing_opaque(source_mesh, &transforms) {
tracing::warn!("scene-loader: enable_mesh_instancing_opaque failed: {err}");
return Ok(());
}
if !def.per_instance_colors.is_empty() {
let attrs: Vec<awsm_renderer::instances::InstanceAttr> =
expand_instance_colors(&def.per_instance_colors, transforms.len())
.into_iter()
.map(|c| awsm_renderer::instances::InstanceAttr::from_rgba_alpha_size(c, 1.0, 1.0))
.collect();
if let Err(err) = renderer.set_mesh_instance_attrs(transform_key, &attrs) {
tracing::warn!("scene-loader: curve per-instance colours failed: {err}");
}
}
Ok(())
}
fn expand_instance_colors(colors: &[[f32; 4]], count: usize) -> Vec<[f32; 4]> {
let last = *colors.last().expect("non-empty per_instance_colors");
(0..count)
.map(|i| colors.get(i).copied().unwrap_or(last))
.collect()
}
fn find_curve(nodes: &[EditorNode], id: NodeId) -> Option<&CurveDef> {
for n in nodes {
if n.id == id {
if let NodeKind::Curve(def) = &n.kind {
return Some(def);
}
}
if let Some(found) = find_curve(&n.children, id) {
return Some(found);
}
}
None
}
fn curve_instance_transforms(curve: &CurveDef, def: &InstancesAlongCurveDef) -> Vec<Transform> {
use awsm_renderer_curves::{Curve3, FrameSequence};
let points: Vec<Vec3> = curve
.control_points
.iter()
.map(|p| Vec3::from_array(*p))
.collect();
if points.len() < 2 {
return Vec::new();
}
let mut crom = awsm_renderer_curves::CatmullRomCurve::new(points, curve.closed);
crom.tension = curve.tension;
let sample_count = curve.sample_count.max(2) as usize;
let total_len = crom.total_length(sample_count);
let spacing = def.spacing.max(1.0e-3);
if total_len <= 0.0 {
return Vec::new();
}
let frames = FrameSequence::parallel_transport(&crom, sample_count, Vec3::Y);
let mut out = Vec::new();
let mut dist = 0.0_f32;
while dist <= total_len + 1.0e-4 {
let t = (dist / total_len).clamp(0.0, 1.0);
let frame_pos = (t * (sample_count - 1) as f32).round() as usize;
let frame = &frames.frames[frame_pos.min(frames.frames.len() - 1)];
let position = frame.position + frame.normal * def.side_offset;
let rotation = if def.orient_to_tangent {
frame.rotation()
} else {
Quat::IDENTITY
};
out.push(Transform {
translation: position,
rotation,
scale: Vec3::ONE,
});
dist += spacing;
}
out
}
fn warn_decal_feature_off() {
use std::sync::atomic::{AtomicBool, Ordering};
static WARNED: AtomicBool = AtomicBool::new(false);
if !WARNED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"scene-loader: Decal node skipped — the renderer's `decals` feature is off, so the \
per-decal GPU pass doesn't exist (would render as 'decal missing')"
);
}
}
#[cfg(feature = "lod")]
const CLUSTER_STREAMING_BUDGET_TRIS: usize = 1_000_000;
#[cfg(feature = "lod")]
fn select_resident_clusters(
cm: &awsm_renderer_lod_bake::ClusterMesh,
budget_tris: usize,
) -> (
Vec<awsm_renderer::cluster_lod::ClusterPage>,
Vec<u32>,
Vec<usize>,
) {
let to_page = |c: &awsm_renderer_lod_bake::ClusterPage, first_index: u32, lod_error: f32| {
awsm_renderer::cluster_lod::ClusterPage {
center: c.center,
radius: c.radius,
lod_error,
parent_error: c.parent_error,
lod_bounds_center: c.lod_bounds_center,
lod_bounds_radius: c.lod_bounds_radius,
parent_bounds_center: c.parent_bounds_center,
parent_bounds_radius: c.parent_bounds_radius,
first_index,
index_count: c.index_count,
}
};
if cm.indices.len() / 3 <= budget_tris {
let pages = cm
.clusters
.iter()
.map(|c| to_page(c, c.first_index, c.lod_error))
.collect();
let ids = (0..cm.clusters.len()).collect();
return (pages, cm.indices.clone(), ids);
}
let mut thresholds: Vec<f32> = cm.clusters.iter().map(|c| c.lod_error).collect();
thresholds.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
thresholds.dedup();
let budget_idx = budget_tris * 3;
let cut_at = |t: f32| -> (usize, Vec<usize>) {
let mut idx = 0usize;
let mut sel = Vec::new();
for (i, c) in cm.clusters.iter().enumerate() {
if c.lod_error <= t && t < c.parent_error {
idx += c.index_count as usize;
sel.push(i);
}
}
(idx, sel)
};
let mut chosen: Option<Vec<usize>> = None;
for &t in &thresholds {
let (idx, sel) = cut_at(t);
if idx <= budget_idx && !sel.is_empty() {
chosen = Some(sel);
break;
}
}
let sel = chosen.unwrap_or_else(|| cut_at(*thresholds.last().unwrap()).1);
let mut m_indices: Vec<u32> = Vec::new();
let mut pages = Vec::new();
for &i in &sel {
let c = &cm.clusters[i];
let first_index = m_indices.len() as u32;
let s = c.first_index as usize;
m_indices.extend_from_slice(&cm.indices[s..s + c.index_count as usize]);
let mut page = to_page(c, first_index, 0.0);
page.parent_error = f32::MAX;
pages.push(page);
}
(pages, m_indices, sel)
}
#[cfg(feature = "lod")]
const CLUSTER_PAGE_VERTS: usize = 384;
#[allow(dead_code)]
#[cfg(feature = "lod")]
const CLUSTER_PAGE_POOL_SLOTS: usize = 8192;
#[cfg(feature = "lod")]
const CLUSTER_PAGING_BUDGET_TRIS: usize = 30_000;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(feature = "lod")]
struct PagePoolPlan {
resident: Vec<i32>,
slots_used: usize,
overflow: usize,
}
#[cfg(feature = "lod")]
fn plan_page_pool(
cluster_count: usize,
resident_cluster_ids: &[usize],
pool_slots: usize,
) -> PagePoolPlan {
let mut resident = vec![-1i32; cluster_count];
let mut slots_used = 0usize;
let mut overflow = 0usize;
for &c in resident_cluster_ids {
if c >= cluster_count {
continue; }
if slots_used < pool_slots {
resident[c] = slots_used as i32;
slots_used += 1;
} else {
overflow += 1;
}
}
PagePoolPlan {
resident,
slots_used,
overflow,
}
}
#[cfg(feature = "lod")]
fn build_slot_geometry(
page_spans: &[(u32, u32)],
m_indices: &[u32],
resident: &[i32],
page_verts: usize,
) -> (Vec<u32>, Vec<u32>) {
let num_slots = resident.iter().filter(|&&s| s >= 0).count();
let mut slot_indices = vec![0u32; num_slots * page_verts];
let mut source_indices = vec![0u32; num_slots * page_verts];
for (c, &(first_index, index_count)) in page_spans.iter().enumerate() {
let slot = resident[c];
if slot < 0 {
continue;
}
let base = slot as usize * page_verts;
let f = first_index as usize;
let ic = (index_count as usize).min(page_verts);
let pad = if ic > 0 { m_indices[f] } else { 0 };
for k in 0..page_verts {
slot_indices[base + k] = if k < ic { m_indices[f + k] } else { pad };
source_indices[base + k] = (base + if k < ic { k } else { 0 }) as u32;
}
}
(slot_indices, source_indices)
}
#[cfg(feature = "lod")]
type DagGroupKey = [u32; 5];
#[allow(dead_code)] #[cfg(feature = "lod")]
fn cluster_finer_group(lod_keys: &[DagGroupKey], parent_keys: &[DagGroupKey]) -> Vec<Vec<usize>> {
use std::collections::HashMap;
let mut by_lod: HashMap<DagGroupKey, Vec<usize>> = HashMap::new();
for (i, k) in lod_keys.iter().enumerate() {
by_lod.entry(*k).or_default().push(i);
}
let mut children = vec![Vec::new(); lod_keys.len()];
for (c, pk) in parent_keys.iter().enumerate() {
if let Some(parents) = by_lod.get(pk) {
for &f in parents {
children[f].push(c);
}
}
}
children
}
#[allow(dead_code)] #[cfg(feature = "lod")]
fn plan_stream_evict(
desired: &[bool], resident: &[i32], slot_cluster: &[i32], slot_last_used: &[u64],
max_loads: usize,
) -> Vec<(usize, usize)> {
let wanted: Vec<usize> = (0..desired.len())
.filter(|&c| desired[c] && resident[c] < 0)
.collect();
let mut free: Vec<usize> = (0..slot_cluster.len())
.filter(|&s| slot_cluster[s] < 0)
.collect();
free.sort_unstable();
let mut evictable: Vec<usize> = (0..slot_cluster.len())
.filter(|&s| {
let c = slot_cluster[s];
c >= 0 && !desired[c as usize]
})
.collect();
evictable.sort_by_key(|&s| (slot_last_used[s], s)); let mut recycle = free.into_iter().chain(evictable);
let mut loads = Vec::new();
for &c in wanted.iter().take(max_loads) {
match recycle.next() {
Some(slot) => loads.push((c, slot)),
None => break, }
}
loads
}
#[cfg(not(feature = "lod"))]
async fn load_cluster_lod(
_renderer: &mut AwsmRenderer,
_assets: &impl SceneAssets,
_asset_id: &str,
_tk: TransformKey,
_mat: MaterialKey,
) -> Result<Option<MeshKey>> {
Ok(None)
}
#[cfg(feature = "lod")]
async fn load_cluster_lod(
renderer: &mut AwsmRenderer,
assets: &impl SceneAssets,
asset_id: &str,
tk: TransformKey,
mat: MaterialKey,
) -> Result<Option<MeshKey>> {
if !renderer.features().virtual_geometry {
return Ok(None);
}
let path = format!(
"{ASSETS_DIR}/{}",
awsm_renderer_lod_bake::cluster_mesh_filename(asset_id)
);
let Ok(bytes) = assets.fetch(&path).await else {
return Ok(None);
};
let cm: awsm_renderer_lod_bake::ClusterMesh = match serde_json::from_slice(&bytes) {
Ok(c) => c,
Err(e) => {
tracing::warn!("cluster LOD: unreadable `{path}`: {e}");
return Ok(None);
}
};
materialize_cluster_mesh(renderer, &cm, asset_id, tk, mat).await
}
#[cfg(feature = "lod")]
pub async fn materialize_cluster_mesh(
renderer: &mut AwsmRenderer,
cm: &awsm_renderer_lod_bake::ClusterMesh,
asset_label: &str,
tk: TransformKey,
mat: MaterialKey,
) -> Result<Option<MeshKey>> {
if !renderer.features().virtual_geometry {
return Ok(None);
}
if cm.positions.is_empty() || cm.clusters.is_empty() {
return Ok(None);
}
let budget = if renderer.features().cluster_paging {
renderer
.features()
.cluster_streaming_budget
.unwrap_or(CLUSTER_PAGING_BUDGET_TRIS)
} else if renderer.features().cluster_streaming {
renderer
.features()
.cluster_streaming_budget
.unwrap_or(CLUSTER_STREAMING_BUDGET_TRIS)
} else {
usize::MAX
};
let (gpu_pages, m_indices, resident_cluster_ids) = select_resident_clusters(cm, budget);
let resident_tris = m_indices.len() / 3;
let capped = m_indices.len() < cm.indices.len();
let resident_page_count = gpu_pages.len();
enum ClusterUploads {
Paging {
gpu_pages_pool: Vec<awsm_renderer::cluster_lod::ClusterPage>,
slot_source: Vec<u32>,
resident_pool: Vec<i32>,
init: awsm_renderer::render_passes::cluster_lod::ClusterPagingInit,
},
Simple {
gpu_pages: Vec<awsm_renderer::cluster_lod::ClusterPage>,
identity_indices: Vec<u32>,
},
}
let (m_geometry_indices, uploads): (Vec<u32>, ClusterUploads) = if renderer
.features()
.cluster_paging
{
let frontier = gpu_pages.len();
let pool_slots = (frontier * 2).max(frontier + 1);
let resident_ids: Vec<usize> = (0..frontier).collect();
let plan = plan_page_pool(frontier, &resident_ids, frontier.max(1));
let page_spans: Vec<(u32, u32)> = gpu_pages
.iter()
.map(|p| (p.first_index, p.index_count))
.collect();
let (mut slot_indices, mut slot_source) =
build_slot_geometry(&page_spans, &m_indices, &plan.resident, CLUSTER_PAGE_VERTS);
slot_indices.resize(pool_slots * CLUSTER_PAGE_VERTS, 0);
slot_source.resize(pool_slots * CLUSTER_PAGE_VERTS, 0);
let spare_page = awsm_renderer::cluster_lod::ClusterPage {
center: [0.0; 3],
radius: 0.0,
lod_error: 0.0,
parent_error: 0.0,
lod_bounds_center: [0.0; 3],
lod_bounds_radius: 0.0,
parent_bounds_center: [0.0; 3],
parent_bounds_radius: 0.0,
first_index: 0,
index_count: 0,
};
let mut gpu_pages_pool = gpu_pages.clone();
gpu_pages_pool.resize(pool_slots, spare_page);
for (c, page) in gpu_pages_pool.iter_mut().take(frontier).enumerate() {
page.first_index = (c * CLUSTER_PAGE_VERTS) as u32;
}
let mut resident_pool = plan.resident.clone();
resident_pool.resize(pool_slots, -1);
let original_pages: Vec<awsm_renderer::cluster_lod::ClusterPage> = cm
.clusters
.iter()
.map(|c| awsm_renderer::cluster_lod::ClusterPage {
center: c.center,
radius: c.radius,
lod_error: c.lod_error,
parent_error: c.parent_error,
lod_bounds_center: c.lod_bounds_center,
lod_bounds_radius: c.lod_bounds_radius,
parent_bounds_center: c.parent_bounds_center,
parent_bounds_radius: c.parent_bounds_radius,
first_index: c.first_index,
index_count: c.index_count,
})
.collect();
let mut slot_cluster: Vec<i32> = resident_cluster_ids.iter().map(|&c| c as i32).collect();
slot_cluster.resize(pool_slots, -1);
let init = awsm_renderer::render_passes::cluster_lod::ClusterPagingInit {
pages: original_pages,
positions: cm.positions.clone(),
normals: cm.normals.clone(),
indices: cm.indices.clone(),
slot_cluster,
page_verts: CLUSTER_PAGE_VERTS,
};
tracing::info!(
"cluster paging (Gap B): page pool — {} slots × {} verts/slot = {} slot verts ({} resident tris capped to budget {}); cut draws the capped frontier crack-free",
plan.slots_used,
CLUSTER_PAGE_VERTS,
slot_indices.len(),
resident_tris,
budget,
);
(
slot_indices,
ClusterUploads::Paging {
gpu_pages_pool,
slot_source,
resident_pool,
init,
},
)
} else {
let identity_indices: Vec<u32> = (0..m_indices.len() as u32).collect();
(
m_indices,
ClusterUploads::Simple {
gpu_pages,
identity_indices,
},
)
};
let m_raw = RawMeshData {
positions: cm.positions.clone(),
normals: (!cm.normals.is_empty()).then(|| cm.normals.clone()),
uv_sets: if cm.uvs.is_empty() {
vec![]
} else {
vec![cm.uvs.clone()]
},
colors: (!cm.colors.is_empty()).then(|| cm.colors.clone()),
indices: m_geometry_indices,
skin: None,
morph: None,
};
let m_key = renderer.add_raw_mesh(m_raw, tk, mat)?;
match uploads {
ClusterUploads::Paging {
gpu_pages_pool,
slot_source,
resident_pool,
init,
} => {
renderer.upload_cluster_pages(m_key, &gpu_pages_pool, &slot_source)?;
renderer.upload_cluster_resident(m_key, &resident_pool)?;
renderer.init_cluster_paging(m_key, init);
}
ClusterUploads::Simple {
gpu_pages,
identity_indices,
} => {
renderer.upload_cluster_pages(m_key, &gpu_pages, &identity_indices)?;
}
}
tracing::info!(
"cluster LOD (GPU): {asset_label} {} clusters ({} resident), render mesh M = {} tris{}, per-cluster cut drives draw",
cm.cluster_count(),
resident_page_count,
resident_tris,
if capped {
format!(
" (CAPPED from {} — streaming residency budget {})",
cm.indices.len() / 3,
budget
)
} else {
String::new()
}
);
Ok(Some(m_key))
}
fn node_lod_enabled(kind: &NodeKind) -> bool {
kind.mesh_lod().map(|l| l.enabled).unwrap_or(false)
}
#[cfg(not(feature = "lod"))]
async fn load_static_lod_chain(
_renderer: &mut AwsmRenderer,
_assets: &impl SceneAssets,
_asset_id: &str,
_base_key: MeshKey,
_tk: TransformKey,
_mat: MaterialKey,
) -> Result<()> {
Ok(())
}
#[cfg(feature = "lod")]
async fn load_static_lod_chain(
renderer: &mut AwsmRenderer,
assets: &impl SceneAssets,
asset_id: &str,
base_key: MeshKey,
tk: TransformKey,
mat: MaterialKey,
) -> Result<()> {
if !renderer.features().lod {
return Ok(());
}
let manifest_path = format!(
"{ASSETS_DIR}/{}",
awsm_renderer_lod_bake::lod_manifest_filename(asset_id)
);
let Ok(bytes) = assets.fetch(&manifest_path).await else {
return Ok(());
};
let manifest: awsm_renderer_lod_bake::MeshLodManifest = match std::str::from_utf8(&bytes)
.ok()
.and_then(|s| toml::from_str(s).ok())
{
Some(m) => m,
None => {
tracing::warn!("lod: ignoring unreadable manifest `{manifest_path}`");
return Ok(());
}
};
let mut levels = Vec::with_capacity(manifest.levels.len());
for lvl in &manifest.levels {
let leaf = awsm_renderer_lod_bake::lod_level_filename(asset_id, lvl.index);
let (keys, _) = load_glb_under(renderer, assets, &leaf, Some(tk), mat).await?;
let Some(&level_key) = keys.first() else {
continue;
};
for &k in &keys {
let _ = renderer.set_mesh_hidden(k, true);
}
levels.push(LodLevel {
mesh_key: level_key,
error: lvl.error,
});
}
if !levels.is_empty() {
renderer.lod.register(
base_key,
LodChain {
levels,
bounds_radius: manifest.bounds_radius,
..Default::default()
},
);
}
Ok(())
}
#[cfg(not(feature = "lod"))]
async fn load_skinned_lod_chain(
_renderer: &mut AwsmRenderer,
_assets: &impl SceneAssets,
_source_id: &str,
_base_key: MeshKey,
_node_index_transforms: &HashMap<usize, TransformKey>,
_mat: MaterialKey,
) -> Result<()> {
Ok(())
}
#[cfg(feature = "lod")]
async fn load_skinned_lod_chain(
renderer: &mut AwsmRenderer,
assets: &impl SceneAssets,
source_id: &str,
base_key: MeshKey,
node_index_transforms: &HashMap<usize, TransformKey>,
mat: MaterialKey,
) -> Result<()> {
use awsm_renderer::meshes::buffer_info::MeshBufferGeometryMorphInfo;
use awsm_renderer::raw_mesh::{RawMeshData, RawMorph, RawSkin};
if !renderer.features().lod {
return Ok(());
}
let manifest_path = format!(
"{ASSETS_DIR}/{}",
awsm_renderer_lod_bake::lod_manifest_filename(source_id)
);
let Ok(bytes) = assets.fetch(&manifest_path).await else {
return Ok(());
};
let manifest: awsm_renderer_lod_bake::MeshLodManifest = match std::str::from_utf8(&bytes)
.ok()
.and_then(|s| toml::from_str(s).ok())
{
Some(m) => m,
None => {
tracing::warn!("lod: ignoring unreadable skinned manifest `{manifest_path}`");
return Ok(());
}
};
let root = renderer.transforms.root_node;
let mut levels = Vec::with_capacity(manifest.levels.len());
for lvl in &manifest.levels {
let leaf = awsm_renderer_lod_bake::lod_level_filename(source_id, lvl.index);
let key = format!("{ASSETS_DIR}/{leaf}");
let Ok(glb) = assets.fetch(&key).await else {
continue;
};
let data = GltfLoader::from_glb_bytes(&glb).await?.into_data(None)?;
let mut level_first: Option<MeshKey> = None;
let node_indices: Vec<usize> = data
.doc
.nodes()
.filter(|n| n.mesh().is_some())
.map(|n| n.index())
.collect();
for node_index in node_indices {
let Some(extracted) = awsm_renderer_glb_export::extract_node_mesh(
&data.doc,
&data.buffers.raw,
node_index as u32,
None,
) else {
continue;
};
let raw_skin = match extracted.skin.as_ref() {
Some(s) => {
let mut joints = Vec::with_capacity(s.joint_node_indices.len());
let mut ok = true;
for rig_idx in &s.joint_node_indices {
match node_index_transforms.get(rig_idx) {
Some(&tk) => joints.push(tk),
None => {
ok = false;
break;
}
}
}
if !ok {
None
} else {
Some(RawSkin {
joints,
inverse_bind_matrices: s
.inverse_bind_matrices
.iter()
.map(glam::Mat4::from_cols_array)
.collect(),
set_count: 1,
index_weights: s.packed_index_weights(),
})
}
}
None => None,
};
let vertex_count = extracted.mesh.positions.len();
let raw_morph = extracted.morph.as_ref().map(|m| {
let values = m.packed_values(vertex_count);
RawMorph {
info: MeshBufferGeometryMorphInfo {
targets_len: m.targets_len(),
vertex_stride_size: m.vertex_stride_size(),
values_size: values.len(),
},
weights: m.weights_bytes(),
values,
}
});
let raw = RawMeshData {
positions: extracted.mesh.positions,
normals: extracted.mesh.normals,
uv_sets: extracted.mesh.uvs,
colors: extracted.mesh.colors,
indices: extracted.mesh.indices,
skin: raw_skin,
morph: raw_morph,
};
let tk = renderer.transforms.insert(Transform::default(), Some(root));
let Ok(mk) = renderer.add_raw_mesh(raw, tk, mat) else {
continue;
};
let _ = renderer.set_mesh_hidden(mk, true);
if level_first.is_none() {
level_first = Some(mk);
}
}
if let Some(mesh_key) = level_first {
levels.push(LodLevel {
mesh_key,
error: lvl.error,
});
}
}
if !levels.is_empty() {
renderer.lod.register(
base_key,
LodChain {
levels,
bounds_radius: manifest.bounds_radius,
..Default::default()
},
);
}
Ok(())
}
pub async fn load_glb_under(
renderer: &mut AwsmRenderer,
assets: &impl SceneAssets,
leaf: &str,
parent: Option<TransformKey>,
material: MaterialKey,
) -> Result<(Vec<MeshKey>, HashMap<usize, TransformKey>)> {
let key = format!("{ASSETS_DIR}/{leaf}");
let bytes = assets
.fetch(&key)
.await
.map_err(|_| anyhow!("bundle is missing mesh glb `{key}`"))?;
let transparent = renderer.materials.is_transparency_pass(material);
let data = GltfLoader::from_glb_bytes(&bytes).await?.into_data(None)?;
let ctx = renderer
.populate_gltf_with(
data,
PopulateGltfOpts {
scene: None,
parent_transform: parent,
material_source: GltfMaterialSource::Single(material),
},
)
.await?;
let (keys, node_index_transforms): (Vec<MeshKey>, HashMap<usize, TransformKey>) = {
let lookups = ctx.key_lookups.lock().unwrap();
let keys = lookups.all_mesh_keys.values().flatten().copied().collect();
(keys, lookups.node_index_to_transform.clone())
};
if transparent {
for &k in &keys {
let _ = renderer.set_mesh_shadow_flags(
k,
awsm_renderer::shadows::MeshShadowFlags {
cast: false,
receive: false,
},
);
}
}
Ok((keys, node_index_transforms))
}
pub async fn materialize_node_mesh(
renderer: &mut AwsmRenderer,
scene: &Scene,
node: &EditorNode,
assets: &impl SceneAssets,
material: MaterialKey,
) -> Result<Vec<MeshKey>> {
let tk = renderer
.transforms
.insert(trs_to_transform(&node.transform), None);
build_node_meshes(renderer, scene, node, tk, material, assets, false).await
}
fn trs_to_transform(trs: &Trs) -> Transform {
Transform {
translation: Vec3::from_array(trs.translation),
rotation: Quat::from_array(trs.rotation),
scale: Vec3::from_array(trs.scale),
}
}
pub fn mesh_data_to_raw(md: awsm_renderer_meshgen::MeshData) -> RawMeshData {
RawMeshData {
positions: md.positions,
normals: md.normals,
uv_sets: md.uvs,
colors: md.colors,
indices: md.indices,
..Default::default()
}
}
async fn resolve_material(
renderer: &mut AwsmRenderer,
instance: Option<&MaterialInstance>,
placeholder: MaterialKey,
assets: &impl SceneAssets,
custom: &HashMap<AssetId, awsm_renderer_materials::MaterialShaderId>,
) -> MaterialKey {
let Some(inst) = instance else {
return placeholder;
};
if let Some(&shader_id) = custom.get(&inst.asset) {
if let Some(mat) = dynamic::build_custom_material(renderer, shader_id, inst, assets).await {
renderer.upload_dynamic_material_buffers(&mat);
return renderer.materials.insert(
mat,
&renderer.textures,
&renderer.dynamic_materials,
&renderer.extras_pool,
);
}
return placeholder;
}
let def = &inst.inline;
let material = match def.shading {
MaterialShading::Pbr => {
let alpha = material::alpha_mode_of(def);
let mut pbr = material::material_to_pbr(def, alpha, None);
use MipmapTextureKind as K;
if let Some(t) = &def.base_color_texture {
pbr.base_color_tex =
texture::load_texture(renderer, assets, t, true, K::Albedo).await;
}
if let Some(t) = &def.metallic_roughness_texture {
pbr.metallic_roughness_tex =
texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
if let Some(t) = &def.normal_texture {
pbr.normal_tex = texture::load_texture(renderer, assets, t, false, K::Normal).await;
}
if let Some(t) = &def.occlusion_texture {
pbr.occlusion_tex =
texture::load_texture(renderer, assets, t, false, K::Occlusion).await;
}
if let Some(t) = &def.emissive_texture {
pbr.emissive_tex =
texture::load_texture(renderer, assets, t, true, K::Emissive).await;
}
bind_extension_textures(renderer, assets, def, &mut pbr).await;
Material::Pbr(Box::new(pbr))
}
_ => material::material_to_renderer(def),
};
renderer.materials.insert(
material,
&renderer.textures,
&renderer.dynamic_materials,
&renderer.extras_pool,
)
}
async fn bind_extension_textures(
renderer: &mut AwsmRenderer,
assets: &impl SceneAssets,
def: &awsm_renderer_scene::MaterialDef,
pbr: &mut awsm_renderer::materials::pbr::PbrMaterial,
) {
use MipmapTextureKind as K;
let ext = &def.extensions;
if let (Some(e), Some(p)) = (ext.specular.as_ref(), pbr.specular.as_mut()) {
if let Some(t) = &e.tex {
p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
if let Some(t) = &e.color_tex {
p.color_tex = texture::load_texture(renderer, assets, t, true, K::Albedo).await;
}
}
if let (Some(e), Some(p)) = (ext.transmission.as_ref(), pbr.transmission.as_mut()) {
if let Some(t) = &e.tex {
p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
}
if let (Some(e), Some(p)) = (
ext.diffuse_transmission.as_ref(),
pbr.diffuse_transmission.as_mut(),
) {
if let Some(t) = &e.tex {
p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
if let Some(t) = &e.color_tex {
p.color_tex = texture::load_texture(renderer, assets, t, true, K::Albedo).await;
}
}
if let (Some(e), Some(p)) = (ext.volume.as_ref(), pbr.volume.as_mut()) {
if let Some(t) = &e.thickness_tex {
p.thickness_tex =
texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
}
if let (Some(e), Some(p)) = (ext.clearcoat.as_ref(), pbr.clearcoat.as_mut()) {
if let Some(t) = &e.tex {
p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
if let Some(t) = &e.roughness_tex {
p.roughness_tex =
texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
if let Some(t) = &e.normal_tex {
p.normal_tex = texture::load_texture(renderer, assets, t, false, K::Normal).await;
}
}
if let (Some(e), Some(p)) = (ext.sheen.as_ref(), pbr.sheen.as_mut()) {
if let Some(t) = &e.color_tex {
p.color_tex = texture::load_texture(renderer, assets, t, true, K::Albedo).await;
}
if let Some(t) = &e.roughness_tex {
p.roughness_tex =
texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
}
if let (Some(e), Some(p)) = (ext.anisotropy.as_ref(), pbr.anisotropy.as_mut()) {
if let Some(t) = &e.tex {
p.tex = texture::load_texture(renderer, assets, t, false, K::Normal).await;
}
}
if let (Some(e), Some(p)) = (ext.iridescence.as_ref(), pbr.iridescence.as_mut()) {
if let Some(t) = &e.tex {
p.tex = texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
if let Some(t) = &e.thickness_tex {
p.thickness_tex =
texture::load_texture(renderer, assets, t, false, K::MetallicRoughness).await;
}
}
}
fn insert_placeholder_material(renderer: &mut AwsmRenderer) -> MaterialKey {
let mut m = UnlitMaterial::new(MaterialAlphaMode::Opaque, false);
m.base_color_factor = [1.0, 0.0, 1.0, 1.0];
renderer.materials.insert(
Material::Unlit(m),
&renderer.textures,
&renderer.dynamic_materials,
&renderer.extras_pool,
)
}
#[cfg(test)]
mod prefab_tests {
use super::{expand_instance_colors, prefab_subtree_layout};
use awsm_renderer_scene::{EditorNode, NodeId, NodeKind};
#[test]
fn instance_colors_repeat_last_when_short_and_truncate_when_long() {
let red = [1.0, 0.0, 0.0, 1.0];
let green = [0.0, 1.0, 0.0, 1.0];
let out = expand_instance_colors(&[red, green], 4);
assert_eq!(out, vec![red, green, green, green]);
let out = expand_instance_colors(&[red, green, red], 2);
assert_eq!(out, vec![red, green]);
let out = expand_instance_colors(&[red, green], 2);
assert_eq!(out, vec![red, green]);
}
fn node(id: NodeId, prefab: bool, children: Vec<EditorNode>) -> EditorNode {
EditorNode {
id,
name: String::new(),
transform: Default::default(),
kind: NodeKind::Group,
locked: false,
visible: true,
prefab,
children,
}
}
#[test]
fn layout_is_dfs_preorder_with_parent_wiring() {
let (root, child1, grandchild, child2) =
(NodeId::new(), NodeId::new(), NodeId::new(), NodeId::new());
let tree = node(
root,
true,
vec![
node(child1, false, vec![node(grandchild, false, vec![])]),
node(child2, false, vec![]),
],
);
let layout = prefab_subtree_layout(&tree);
let plan: Vec<_> = layout
.iter()
.map(|s| (s.node.id, s.parent, s.nested_prefab))
.collect();
assert_eq!(
plan,
vec![
(root, None, false),
(child1, Some(root), false),
(grandchild, Some(child1), false),
(child2, Some(root), false),
]
);
}
#[test]
fn nested_prefab_is_a_boundary_and_excludes_its_descendants() {
let (root, nested, deep) = (NodeId::new(), NodeId::new(), NodeId::new());
let tree = node(
root,
true,
vec![node(nested, true, vec![node(deep, false, vec![])])],
);
let layout = prefab_subtree_layout(&tree);
let ids: Vec<_> = layout.iter().map(|s| s.node.id).collect();
assert_eq!(ids, vec![root, nested]);
let nested_step = layout.iter().find(|s| s.node.id == nested).unwrap();
assert!(nested_step.nested_prefab);
assert_eq!(nested_step.parent, Some(root));
assert!(!layout[0].nested_prefab);
}
}
#[cfg(all(test, feature = "lod"))]
mod cluster_streaming_tests {
use super::{
build_slot_geometry, cluster_finer_group, plan_page_pool, plan_stream_evict,
select_resident_clusters,
};
#[test]
fn stream_evict_fills_free_slots_first() {
let loads = plan_stream_evict(&[true, true, false], &[-1, -1, -1], &[-1, -1], &[0, 0], 10);
assert_eq!(loads, vec![(0, 0), (1, 1)]); }
#[test]
fn stream_evict_evicts_coldest_non_desired_when_full() {
let loads = plan_stream_evict(
&[true, false, false],
&[-1, 0, 1], &[1, 2], &[5, 2], 10,
);
assert_eq!(loads, vec![(0, 1)]); }
#[test]
fn stream_evict_honours_max_loads_and_skips_resident() {
let loads = plan_stream_evict(
&[true, true, true, true],
&[-1, -1, -1, 0],
&[3, -1, -1, -1],
&[9, 0, 0, 0],
2,
);
assert_eq!(loads, vec![(0, 1), (1, 2)]); }
use awsm_renderer_lod_bake::{
build_cluster_dag, ClusterMesh, ClusterPage, DagOptions, ROOT_PARENT_ERROR,
};
#[test]
fn finer_group_links_synthetic_two_level_dag() {
let k0 = [0u32, 0, 0, 0, 0];
let k1 = [1u32, 0, 0, 0, 0];
let kg = [9u32, 9, 9, 9, 9];
let kroot = [u32::MAX; 5];
let lod = vec![k0, k1, kg];
let par = vec![kg, kg, kroot];
let ch = cluster_finer_group(&lod, &par);
assert_eq!(ch[0], Vec::<usize>::new()); assert_eq!(ch[1], Vec::<usize>::new());
assert_eq!(ch[2], vec![0, 1]); }
#[test]
fn finer_group_links_real_dag_cover_all_non_roots() {
let (pos, idx) = uv_sphere(24, 16);
let dag = build_cluster_dag(&pos, &idx, &DagOptions::default());
let key = |e: f32, c: [f32; 3], r: f32| {
[
e.to_bits(),
c[0].to_bits(),
c[1].to_bits(),
c[2].to_bits(),
r.to_bits(),
]
};
let lod: Vec<_> = dag
.clusters
.iter()
.map(|c| key(c.lod_error, c.lod_bounds_center, c.lod_bounds_radius))
.collect();
let par: Vec<_> = dag
.clusters
.iter()
.map(|c| {
key(
c.parent_error,
c.parent_bounds_center,
c.parent_bounds_radius,
)
})
.collect();
let ch = cluster_finer_group(&lod, &par);
assert!(
ch.iter().any(|g| !g.is_empty()),
"DAG must have refinement groups"
);
let mut is_child = vec![false; dag.clusters.len()];
for g in &ch {
for &c in g {
is_child[c] = true;
}
}
for (i, c) in dag.clusters.iter().enumerate() {
if c.parent_error < ROOT_PARENT_ERROR {
assert!(
is_child[i],
"non-root cluster {i} must be in some finer-group"
);
}
}
}
#[test]
fn slot_geometry_packs_padded_slots_and_preserves_triangles() {
let spans = [(0u32, 3u32), (3, 3)]; let m_indices = vec![10u32, 11, 12, 20, 21, 22];
let resident = vec![0i32, 1];
let (slot_indices, source_indices) = build_slot_geometry(&spans, &m_indices, &resident, 4);
assert_eq!(slot_indices, vec![10, 11, 12, 10, 20, 21, 22, 20]);
assert_eq!(source_indices, vec![0, 1, 2, 0, 4, 5, 6, 4]);
for (c, &(f, ic)) in spans.iter().enumerate() {
let (f, ic) = (f as usize, ic as usize);
let slot_first = resident[c] as usize * 4;
let drawn: Vec<u32> = source_indices[slot_first..slot_first + ic]
.iter()
.map(|&s| slot_indices[s as usize])
.collect();
assert_eq!(drawn, m_indices[f..f + ic].to_vec());
}
}
#[test]
fn page_pool_assigns_one_slot_per_resident_cluster() {
let plan = plan_page_pool(5, &[0, 2, 4], 8);
assert_eq!(plan.resident, vec![0, -1, 1, -1, 2]);
assert_eq!(plan.slots_used, 3);
assert_eq!(plan.overflow, 0);
}
#[test]
fn page_pool_overflows_past_capacity() {
let plan = plan_page_pool(4, &[0, 1, 2, 3], 2);
assert_eq!(plan.slots_used, 2);
assert_eq!(plan.overflow, 2);
assert_eq!(plan.resident, vec![0, 1, -1, -1]);
}
#[test]
fn page_pool_ignores_out_of_range_ids() {
let plan = plan_page_pool(2, &[0, 9, 1], 8);
assert_eq!(plan.resident, vec![0, 1]);
assert_eq!(plan.slots_used, 2);
assert_eq!(plan.overflow, 0);
}
#[test]
fn a2_residency_is_bounded_by_budget_not_source() {
let budget = 2_000usize;
let m_tris = |long: usize, lat: usize| -> (usize, usize) {
let (pos, indices) = uv_sphere(long, lat);
let dag = build_cluster_dag(&pos, &indices, &DagOptions::default());
let cm = ClusterMesh::from_dag(&dag, pos, vec![], vec![], vec![]);
let full = cm.indices.len() / 3;
let (_pages, m_indices, _ids) = select_resident_clusters(&cm, budget);
(full, m_indices.len() / 3)
};
let (full_small, m_small) = m_tris(48, 32);
let (full_big, m_big) = m_tris(96, 64); assert!(
full_big > full_small * 2,
"test setup: big DAG ({full_big}) should dwarf small ({full_small})"
);
assert!(
m_small <= budget,
"small source M={m_small} exceeded budget {budget}"
);
assert!(
m_big <= budget,
"big source M={m_big} exceeded budget {budget}"
);
assert!(
m_big <= budget && m_small <= budget,
"residency must track the budget, not source size (m_small={m_small}, m_big={m_big}, budget={budget})"
);
}
#[test]
fn a3_cut_bounded_by_screen_not_source() {
use awsm_renderer::cluster_lod::{select_cut, ClusterPage};
let page = |lod: f32, parent: f32, first_index: u32| ClusterPage {
center: [0.0; 3],
radius: 1.0,
lod_error: lod,
parent_error: parent,
lod_bounds_center: [0.0; 3],
lod_bounds_radius: 1.0,
parent_bounds_center: [0.0; 3],
parent_bounds_radius: 1.0,
first_index,
index_count: 384,
};
let coarse: Vec<ClusterPage> = (0..4).map(|r| page(1.0, f32::INFINITY, r * 384)).collect();
let mut cut = Vec::new();
select_cut(&coarse, 1.5, &mut cut);
assert_eq!(cut.len(), 4, "coarse DAG: 4 regions selected at T=1.5");
let mut refined = coarse.clone();
for r in 0..4u32 {
for c in 0..4u32 {
refined.push(page(0.0, 1.0, 10_000 + r * 100 + c)); for g in 0..4u32 {
refined.push(page(0.0, 0.5, 50_000 + r * 1000 + c * 10 + g));
}
}
}
assert_eq!(refined.len(), 84, "refined DAG has 21× the source clusters");
select_cut(&refined, 1.5, &mut cut);
assert_eq!(
cut.len(),
4,
"refined DAG: cut stays 4 at T=1.5 despite 21× the source — bounded by \
screen-space error budget, not source size"
);
}
#[test]
fn a6_benchmark_table_recorded() {
const BENCH: &str = include_str!("../../../../docs/nanite-lod-benchmark.md");
for needle in [
"1,081,344", "2,393,468", "29,850", "83 MB", "14,835", "4,908", ] {
assert!(
BENCH.contains(needle),
"A6 benchmark table missing verified figure `{needle}` — \
docs/nanite-lod-benchmark.md must record the multi-M-tri bench"
);
}
}
use std::collections::HashMap;
fn uv_sphere(long: usize, lat: usize) -> (Vec<[f32; 3]>, Vec<u32>) {
use std::f32::consts::PI;
const TAU: f32 = 2.0 * PI;
let mut pos = Vec::new();
for la in 0..=lat {
let theta = (la as f32 / lat as f32) * PI;
let (st, ct) = (theta.sin(), theta.cos());
for lo in 0..=long {
let phi = (lo as f32 / long as f32) * TAU;
pos.push([st * phi.cos(), ct, st * phi.sin()]);
}
}
let stride = long + 1;
let mut idx = Vec::new();
for la in 0..lat {
for lo in 0..long {
let a = (la * stride + lo) as u32;
let b = (la * stride + lo + 1) as u32;
let c = ((la + 1) * stride + lo + 1) as u32;
let d = ((la + 1) * stride + lo) as u32;
idx.extend_from_slice(&[a, b, c, a, c, d]);
}
}
(pos, idx)
}
fn weld_ids(pos: &[[f32; 3]], eps: f32) -> Vec<u32> {
let mut map: HashMap<(i64, i64, i64), u32> = HashMap::new();
let q = |v: f32| (v / eps).round() as i64;
pos.iter()
.map(|p| {
let key = (q(p[0]), q(p[1]), q(p[2]));
let n = map.len() as u32;
*map.entry(key).or_insert(n)
})
.collect()
}
fn boundary_edges(tris: &[[u32; 3]], weld: &[u32]) -> usize {
let mut edges: HashMap<(u32, u32), u32> = HashMap::new();
for t in tris {
let w = [
weld[t[0] as usize],
weld[t[1] as usize],
weld[t[2] as usize],
];
if w[0] == w[1] || w[1] == w[2] || w[0] == w[2] {
continue;
}
for (a, b) in [(w[0], w[1]), (w[1], w[2]), (w[2], w[0])] {
let key = if a < b { (a, b) } else { (b, a) };
*edges.entry(key).or_insert(0) += 1;
}
}
edges.values().filter(|&&c| c == 1).count()
}
#[test]
fn capped_resident_cut_is_crack_free() {
let (pos, indices) = uv_sphere(48, 32);
let weld = weld_ids(&pos, 1e-3);
let dag = build_cluster_dag(&pos, &indices, &DagOptions::default());
let cm = ClusterMesh::from_dag(&dag, pos, vec![], vec![], vec![]);
let full_tris = cm.indices.len() / 3;
let budget = full_tris / 4; let (pages, m_indices, _ids) = select_resident_clusters(&cm, budget);
let mut tris: Vec<[u32; 3]> = Vec::new();
for p in &pages {
if p.lod_error <= 0.0 && 0.0 < p.parent_error {
let s = p.first_index as usize;
let e = s + p.index_count as usize;
for c in m_indices[s..e].chunks_exact(3) {
tris.push([c[0], c[1], c[2]]);
}
}
}
assert!(!tris.is_empty(), "capped cut selected no leaves");
let holes = boundary_edges(&tris, &weld);
assert_eq!(
holes, 0,
"capped cluster cut (budget {budget} tris) tore {holes} hole edge(s) — \
partial-frontier seam; resident set must be a complete antichain (A1 capped)"
);
}
fn page(lod_error: f32, parent_error: f32, first_index: u32, index_count: u32) -> ClusterPage {
ClusterPage {
center: [0.0; 3],
radius: 1.0,
lod_error,
parent_error,
lod_bounds_center: [0.0; 3],
lod_bounds_radius: 1.0,
parent_bounds_center: [0.0; 3],
parent_bounds_radius: 1.0,
first_index,
index_count,
}
}
fn fixture() -> ClusterMesh {
ClusterMesh {
positions: vec![[0.0; 3]; 9],
normals: vec![],
uvs: vec![],
colors: vec![],
indices: vec![0, 1, 2, 3, 4, 5, 6, 7, 8],
clusters: vec![
page(0.0, 5.0, 0, 3), page(0.0, 5.0, 3, 3), page(5.0, f32::MAX, 6, 3), ],
}
}
#[test]
fn under_budget_is_verbatim_passthrough() {
let cm = fixture();
let (pages, m_indices, _ids) = select_resident_clusters(&cm, 100);
assert_eq!(m_indices, cm.indices);
assert_eq!(pages.len(), 3);
for (p, c) in pages.iter().zip(cm.clusters.iter()) {
assert_eq!(p.first_index, c.first_index);
assert_eq!(p.lod_error, c.lod_error);
}
}
#[test]
fn cap_to_one_tri_keeps_root_and_clamps_it() {
let cm = fixture();
let (pages, m_indices, _ids) = select_resident_clusters(&cm, 1);
assert_eq!(pages.len(), 1);
assert_eq!(m_indices, vec![6, 7, 8]); assert_eq!(pages[0].first_index, 0);
assert_eq!(pages[0].lod_error, 0.0); assert_eq!(pages[0].parent_error, f32::MAX);
}
#[test]
fn cap_to_two_tris_selects_the_complete_leaf_antichain() {
let cm = fixture();
let (pages, m_indices, _ids) = select_resident_clusters(&cm, 2);
assert_eq!(pages.len(), 2);
assert_eq!(m_indices, vec![0, 1, 2, 3, 4, 5]); for p in &pages {
assert_eq!(p.lod_error, 0.0); assert_eq!(p.parent_error, f32::MAX);
}
}
}