From ec141a06654610fa154d30c25bb5d39e11e5a749 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Wed, 7 Oct 2026 09:00:25 -0700 Subject: [PATCH 01/11] [Enhancement] Add animation playback helpers for synchronized gameplay (#1316) * [Patch] Add animation playback helpers for synchronized gameplay Adds restartAnimation, getAnimationPlaybackTime, and sampleAnimationJointPosition so callers can replay a one-shot clip from time zero, read its current playback time, and sample a named joint in world space at an arbitrary time without changing the displayed pose. ClipSampler.sample() also unconditionally wrapped time via fmod, even for clips whose channels were flagged non-repeating, defeating the per-channel hold-last-pose logic and causing a completed one-shot clip to jump back to its initial pose. It now clamps to [0, duration] when no animated channel repeats, and only wraps for repeating clips. restartAnimation preserves warmed compiled-pose caches unless the clip's looping configuration actually changes. Fixes #1314. * [Bugfix] Stop root motion from wrapping at a one-shot clip's duration applyRootMotion computed its own channelTime via fmod(currentTime, clipDuration), unconditionally, regardless of whether the clip's channels repeat. Once currentTime passed a non-repeating clip's duration, this wrapped back near zero, which wrappedChannelTime then misread as a fresh loop and injected a full loop's root displacement at that instant -- a visible snap right as a one-shot animation (e.g. a kick) finished, even though ClipSampler already holds the displayed pose correctly past that point. Match ClipSampler: clamp to [0, duration] when no animated channel repeats, only wrap when at least one does. * [Patch] Update animation clip sampler and root motion --- .../UntoldEngine/Animation/ClipSampler.swift | 23 +++- .../UntoldEngine/Animation/RootMotion.swift | 4 +- .../Systems/AnimationSystem.swift | 72 ++++++++++ .../AnimationCompiledSamplerTests.swift | 125 ++++++++++++++++++ .../AnimationRootMotionTests.swift | 32 +++++ 5 files changed, 254 insertions(+), 2 deletions(-) diff --git a/Sources/UntoldEngine/Animation/ClipSampler.swift b/Sources/UntoldEngine/Animation/ClipSampler.swift index 93ad2b00d..b1cda92b7 100644 --- a/Sources/UntoldEngine/Animation/ClipSampler.swift +++ b/Sources/UntoldEngine/Animation/ClipSampler.swift @@ -10,6 +10,27 @@ import simd +/// Scales `time` by `speed` and either wraps it by `duration` (repeating +/// clips) or clamps it to `[0, duration]` (one-shot clips), so a completed +/// one-shot holds its last pose instead of jumping back to pose zero on the +/// next clip-duration boundary. Shared by `ClipSampler.sample` and +/// `applyRootMotion` so the two stay in lockstep. +/// +/// Classifies the whole clip from its first animated channel's `repeats` +/// flag. `Animation.repeatAnimation` is never set per-channel anywhere in +/// the engine — `restartAnimation` is the only mutator and it always writes +/// every joint of a clip to the same value — so every animated channel is +/// expected to agree; a debug-build assert catches the invariant breaking +/// rather than silently mis-sampling the channels that disagree. +func clipLevelChannelTime(time: Float, duration: Float, speed: Float, channels: [CompiledAnimationClip.Channel]) -> Float { + let repeats = channels.first(where: \.animated)?.repeats ?? true + assert( + channels.allSatisfy { !$0.animated || $0.repeats == repeats }, + "clipLevelChannelTime assumes every animated channel in a clip shares the same repeat flag" + ) + return (repeats ? fmod(time, duration) : min(max(time, 0), duration)) * speed +} + /// Samples a `CompiledAnimationClip` into a `PoseBuffer` without allocating. /// /// Keyframe intervals are located with a binary search seeded by a per-joint @@ -44,7 +65,7 @@ struct ClipSampler { bind(clip) pose.resize(jointCount: clip.jointCount) - let channelTime = fmod(time, duration) * speed + let channelTime = clipLevelChannelTime(time: time, duration: duration, speed: speed, channels: clip.channels) for index in 0 ..< clip.jointCount { let channel = clip.channels[index] diff --git a/Sources/UntoldEngine/Animation/RootMotion.swift b/Sources/UntoldEngine/Animation/RootMotion.swift index d0045a51c..4f66f221c 100644 --- a/Sources/UntoldEngine/Animation/RootMotion.swift +++ b/Sources/UntoldEngine/Animation/RootMotion.swift @@ -204,7 +204,9 @@ func applyRootMotion( // wrap, to detect when the clip looped between frames. Non-repeating // channels clamp at their last key (like the sampler), so a one-shot // clip never fakes a wrap and never injects a per-loop correction. - let channelTime = fmod(animationComponent.currentTime, clipDuration) * clipSpeed + let channelTime = clipLevelChannelTime( + time: animationComponent.currentTime, duration: clipDuration, speed: clipSpeed, channels: compiledClip.channels + ) let translationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.translationTimes.last, repeats: channel.repeats) let rotationTime = wrappedChannelTime(channelTime, lastKeyTime: channel.rotationTimes.last, repeats: channel.repeats) diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index 593d87c15..dc759cec9 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -1027,3 +1027,75 @@ public func removeAnimationClip(entityId: EntityID, animationClip: String) { animationComponent.removeAnimationClip(animationClip: animationClip) } } + +/// Restarts a named clip from time zero, even when it is already selected. +/// Call on the engine update thread. Applies to every animated descendant. +/// No-op (reports `.noAnimationClip`) if `entityId` and its animated descendants have no clip +/// registered under `name`. +/// +/// `repeats`, when non-nil, overwrites the clip's own looping configuration +/// for every future playback, not just this restart — `AnimationClip` +/// instances are shared by reference from `animationComponent.animationClips`, +/// so this is a persistent change to the asset, not a one-shot playback flag. +/// Pass `nil` (the default) to restart without touching the clip's current +/// looping behavior. +public func restartAnimation(entityId: EntityID, name: String, repeats: Bool? = nil, withPause: Bool = false) { + let components = animationComponentsContainingClip(entityId: entityId, name: name) + guard !components.isEmpty else { + handleError(.noAnimationClip, name, entityId) + return + } + for (_, component, clip) in components { + // Restarting playback does not change compiled channels. Invalidate only + // this clip when its looping setting changes; preserve warmed caches. + if let repeats, clip.jointAnimation.values.contains(where: { $0.repeatAnimation != repeats }) { + for path in clip.jointAnimation.keys { + clip.jointAnimation[path]?.repeatAnimation = repeats + } + component.compiledClips.removeValue(forKey: ObjectIdentifier(clip)) + } + component.transition.cancel() + component.currentAnimation = clip + component.currentTime = 0 + component.pause = withPause + component.rootMotion.resetHistory() + } +} + +/// Playback time of the first animated part of a root or split character. +/// Returns nil if no part has a selected clip. Read on the engine update thread. +public func getAnimationPlaybackTime(entityId: EntityID) -> Float? { + animationComponentsForEntityOrDescendants(entityId: entityId) + .first(where: { $0.1.currentAnimation != nil })?.1.currentTime +} + +/// Samples a named joint at a clip time without changing the displayed pose. +/// Returns world space. Call on the engine update thread after mesh loading. +/// Returns nil if `name` has no registered clip, `jointName` is not found in the skeleton, or the +/// target entity is missing its skeleton/world-transform components. +public func sampleAnimationJointPosition(entityId: EntityID, name: String, jointName: String, time: Float) -> SIMD3? { + for (target, component, clip) in animationComponentsContainingClip(entityId: entityId, name: name) { + guard let skeleton = scene.get(component: SkeletonComponent.self, for: target)?.skeleton, + let joint = skeleton.jointPaths.firstIndex(where: { + $0.split(separator: "/").last?.split(separator: ":").last.map(String.init) == jointName + }), let world = scene.get(component: WorldTransformComponent.self, for: target) else { continue } + let compiled = component.compiledClip(for: clip, skeleton: skeleton) + var sampler = ClipSampler() + var pose = PoseBuffer() + sampler.sample(compiled, time: time, duration: clip.duration, speed: clip.speed, into: &pose) + var models = [simd_float4x4](repeating: matrix_identity_float4x4, count: pose.jointCount) + for index in 0 ..< pose.jointCount { + let local = simd_float4x4(translation: pose.translations[index]) + * simd_float4x4(pose.rotations[index]) + * simd_float4x4(scale: compiled.restScales[index]) + if let parent = skeleton.parentIndices[index] { + models[index] = models[parent] * local + } else { + models[index] = local + } + } + let point = world.space * models[joint] * SIMD4(0, 0, 0, 1) + return SIMD3(point.x, point.y, point.z) + } + return nil +} diff --git a/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift b/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift index 8b7f7fa77..f67b076ae 100644 --- a/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift +++ b/Tests/UntoldEngineTests/AnimationCompiledSamplerTests.swift @@ -452,3 +452,128 @@ final class AnimationCompiledSamplerTests: XCTestCase { } } } + +extension AnimationCompiledSamplerTests { + func testNonRepeatingClipHoldsFinalPoseAfterItsDuration() { + let skeleton = makeSkeleton() + let clip = makeWalkClip() + for path in clip.jointAnimation.keys { + clip.jointAnimation[path]?.repeatAnimation = false + } + let compiled = CompiledAnimationClip(clip: clip, skeleton: skeleton) + var sampler = ClipSampler() + var pose = PoseBuffer() + sampler.sample(compiled, time: 2.1, duration: clip.duration, speed: 1, into: &pose) + XCTAssertEqual(pose.translations[0].z, 2, accuracy: 0.0001) + sampler.sample(compiled, time: 4.1, duration: clip.duration, speed: 1, into: &pose) + XCTAssertEqual(pose.translations[0].z, 2, accuracy: 0.0001) + } + + func testRestartSameClipResetsTimeAndRearmsPlayback() { + let entity = createEntity() + defer { destroyEntity(entityId: entity) } + registerComponent(entityId: entity, componentType: AnimationComponent.self) + guard let component = scene.get(component: AnimationComponent.self, for: entity) else { + XCTFail("Missing animation component"); return + } + let clip = makeWalkClip() + component.animationClips["strike"] = clip + component.currentAnimation = clip + component.currentTime = 0.8 + component.pause = true + restartAnimation(entityId: entity, name: "strike") + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) + XCTAssertFalse(isAnimationComponentPaused(entityId: entity)) + // repeats defaults to nil: a restart with no explicit looping change + // must not touch the clip's existing (default-true) repeat flags. + XCTAssertTrue(clip.jointAnimation.values.allSatisfy(\.repeatAnimation)) + component.currentTime = 0.7 + restartAnimation(entityId: entity, name: "strike", withPause: true) + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) + XCTAssertTrue(isAnimationComponentPaused(entityId: entity)) + } + + /// `AnimationClip` instances are shared by reference from + /// `animationComponent.animationClips`, so a restart that defaulted to + /// overwriting the loop flag would permanently convert a looping clip to + /// one-shot the first time a caller restarted it without passing + /// `repeats: true`. `repeats: nil` (the default) must leave it alone. + func testRestartWithoutRepeatsArgumentPreservesLoopingClip() { + let entity = createEntity() + defer { destroyEntity(entityId: entity) } + registerComponent(entityId: entity, componentType: AnimationComponent.self) + guard let component = scene.get(component: AnimationComponent.self, for: entity) else { + XCTFail("Missing animation component"); return + } + let clip = makeWalkClip() + XCTAssertTrue(clip.jointAnimation.values.allSatisfy(\.repeatAnimation)) + component.animationClips["walk"] = clip + component.currentAnimation = clip + component.currentTime = 1.3 + restartAnimation(entityId: entity, name: "walk") + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) + XCTAssertTrue(clip.jointAnimation.values.allSatisfy(\.repeatAnimation)) + } +} + +extension AnimationCompiledSamplerTests { + func testContactJointSamplingUsesWorldTransformWithoutChangingPlayback() { + let entity = createEntity() + defer { destroyEntity(entityId: entity) } + registerComponent(entityId: entity, componentType: SkeletonComponent.self) + registerComponent(entityId: entity, componentType: AnimationComponent.self) + guard let skeletonComponent = scene.get(component: SkeletonComponent.self, for: entity), + let component = scene.get(component: AnimationComponent.self, for: entity) + else { + XCTFail("Missing components"); return + } + skeletonComponent.skeleton = makeSkeleton() + let clip = makeWalkClip() + component.animationClips["strike"] = clip + component.currentAnimation = clip + component.currentTime = 0.2 + component.pause = true + translateTo(entityId: entity, position: SIMD3(5, 2, -3)) + rotateTo(entityId: entity, rotation: simd_quatf(angle: .pi / 2, axis: SIMD3(0, 1, 0))) + scaleTo(entityId: entity, scale: SIMD3(repeating: 2)) + let reference = makeSkeleton() + reference.updateWorldPose(at: 0.5, animationClip: clip) + guard let world = scene.get(component: WorldTransformComponent.self, for: entity), + let sampled = sampleAnimationJointPosition(entityId: entity, name: "strike", jointName: "leg", time: 0.5) + else { + XCTFail("Missing sampled joint"); return + } + let expected = world.space * reference.currentPose[3] * reference.bindTransform[3] * SIMD4(0, 0, 0, 1) + XCTAssertEqual(sampled.x, expected.x, accuracy: 0.0001) + XCTAssertEqual(sampled.y, expected.y, accuracy: 0.0001) + XCTAssertEqual(sampled.z, expected.z, accuracy: 0.0001) + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0.2) + XCTAssertTrue(isAnimationComponentPaused(entityId: entity)) + XCTAssertNil(sampleAnimationJointPosition(entityId: entity, name: "strike", jointName: "missing", time: 0.5)) + } +} + +extension AnimationCompiledSamplerTests { + func testRestartPreservesCompiledClipUntilLoopingChanges() { + let entity = createEntity() + defer { destroyEntity(entityId: entity) } + registerComponent(entityId: entity, componentType: AnimationComponent.self) + guard let component = scene.get(component: AnimationComponent.self, for: entity) else { + XCTFail("Missing component"); return + } + let clip = makeWalkClip() + component.animationClips["strike"] = clip + let skeleton = makeSkeleton() + _ = component.compiledClip(for: clip, skeleton: skeleton) + let key = ObjectIdentifier(clip) + restartAnimation(entityId: entity, name: "strike", repeats: true) + XCTAssertEqual(component.compiledClips.count, 1) + XCTAssertNotNil(component.compiledClips[key]) + restartAnimation(entityId: entity, name: "strike", repeats: false) + XCTAssertNil(component.compiledClips[key]) + _ = component.compiledClip(for: clip, skeleton: skeleton) + restartAnimation(entityId: entity, name: "strike", repeats: false) + XCTAssertNotNil(component.compiledClips[key]) + XCTAssertEqual(getAnimationPlaybackTime(entityId: entity), 0) + } +} diff --git a/Tests/UntoldEngineTests/AnimationRootMotionTests.swift b/Tests/UntoldEngineTests/AnimationRootMotionTests.swift index cd3865040..3613121b3 100644 --- a/Tests/UntoldEngineTests/AnimationRootMotionTests.swift +++ b/Tests/UntoldEngineTests/AnimationRootMotionTests.swift @@ -265,6 +265,38 @@ final class AnimationRootMotionTests: XCTestCase { ) } + func testNonRepeatingClipHoldsPositionPastClipDurationWithoutLoopJump() { + animationComponent.animationClips["lunge"] = makeOneShotClip() + + setRootMotionEnabled(entityId: entityId, enabled: true) + changeAnimation(entityId: entityId, name: "lunge", transitionHalflife: 0) + + // 3 s: past the clip's own 2 s duration, not just the root channel's 1 s + // last key. Before the fix, applyRootMotion's own + // fmod(animationComponent.currentTime, clipDuration) wrapped unconditionally + // at the 2 s mark regardless of repeatAnimation, handing wrappedChannelTime + // a small post-wrap time it misread as a fresh loop and injecting a full + // loop's root displacement right at that instant — distinct from + // testNonRepeatingChannelDoesNotInjectLoopJump above, which only runs up to + // 1.89 s and so never reaches the clip's own duration. + var previousZ = getLocalPosition(entityId: entityId).z + var maxStep: Float = 0 + run(frames: 270) { _ in + let z = getLocalPosition(entityId: self.entityId).z + maxStep = max(maxStep, abs(z - previousZ)) + previousZ = z + } + + XCTAssertEqual( + getLocalPosition(entityId: entityId).z, 1.0 - deltaTime, accuracy: 1e-3, + "Entity holds the authored 1 m travel; the clip's own duration elapsing must not add displacement" + ) + XCTAssertLessThan( + maxStep, deltaTime * 1.5, + "currentTime crossing the clip's own duration must not be misread as a loop wrap" + ) + } + // MARK: - Pitch and roll preservation /// The swing–twist split must remove only yaw. A body-frame lean — From 6b7b664687cf52af8f38cc31ab1667a41f1850d4 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 8 Oct 2026 18:44:22 +0200 Subject: [PATCH 02/11] [Bugfix] Export the material a slot linked to the object shows, not the mesh's default (#1320) * [Bugfix] Export the material a slot linked to the object shows, not the mesh's default A multi-material object is split into one mesh per material before export. The split took each piece's material from the mesh data, so a slot linked to the object (Blender's slot link Object) exported the mesh's default instead of the material the object shows. An IFC import gives hundreds of objects one shared mesh and links each one's own material over it: in the BIM site 771 of 811 object-linked slots differ from their mesh's material, and every classroom chair came out with the red material its shared mesh holds where Blender shows wood. Both split paths now ask the object's slot first (slot_material), and a rigged piece's slot is left reading from its one data material. Documented in UsingTheExporter.md, checked in scripts/tests/blender/material_slot_checks.py: the plain split, linked duplicates with their own materials, the export preparation path and a rigged object. * [Patch] Resolve a material slot in one place: mesh_object_material goes through _slot_material The split paths and mesh_object_material resolved a slot the same way in two places (the object's slot first, the mesh data for what the slots do not cover). mesh_object_material now takes its primary lookup from _slot_material and only keeps its own fallback to the first material; the helper carries the file's underscore convention, since only the split paths use it. --------- Co-authored-by: Javier Segura --- docs/API/UsingTheExporter.md | 4 + scripts/tests/README.md | 4 + scripts/tests/blender/material_slot_checks.py | 117 ++++++++++++++++++ scripts/untoldexplorer.py | 41 +++--- 4 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 scripts/tests/blender/material_slot_checks.py diff --git a/docs/API/UsingTheExporter.md b/docs/API/UsingTheExporter.md index ab903cee4..7b67f4f67 100644 --- a/docs/API/UsingTheExporter.md +++ b/docs/API/UsingTheExporter.md @@ -138,6 +138,10 @@ A whole-scene export (no `--mesh-name`) follows what Blender itself shows: - Modifiers, Geometry Nodes and shape keys are applied, including on objects split into one mesh per material. Objects deformed by an Armature modifier keep their rest pose and skinning. +- A material slot linked to the object (Blender's slot link set to Object rather + than Data) exports the object's material, as the viewport shows it. An IFC import + gives hundreds of objects one shared mesh and links each one's own material over + it; the cook follows the object, not the mesh's default. - An object whose parent is not exported (skipped, or split into one mesh per material) keeps its place in the scene. diff --git a/scripts/tests/README.md b/scripts/tests/README.md index 5a4f01365..620c16ad2 100644 --- a/scripts/tests/README.md +++ b/scripts/tests/README.md @@ -47,10 +47,14 @@ temporary folder. - `blender/material_bake_checks.py`: the procedural material bake. Tiles cut at whole bricks, noise that repeats without a seam, and the projected UVs: what Blender shows on a mesh through them is what the baked texture holds. +- `blender/material_slot_checks.py`: a multi-material object split for export + keeps the material each slot shows, including a slot linked to the object over + a shared mesh, for linked duplicates and for a rigged object. Run from the repo root: ```sh blender --background --factory-startup --python-exit-code 1 --python scripts/tests/blender/texture_write_checks.py blender --background --factory-startup --python-exit-code 1 --python scripts/tests/blender/material_bake_checks.py +blender --background --factory-startup --python-exit-code 1 --python scripts/tests/blender/material_slot_checks.py ``` diff --git a/scripts/tests/blender/material_slot_checks.py b/scripts/tests/blender/material_slot_checks.py new file mode 100644 index 000000000..f3da8effa --- /dev/null +++ b/scripts/tests/blender/material_slot_checks.py @@ -0,0 +1,117 @@ +# Copyright (C) Untold Engine Studios +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +"""Checks that a multi-material object keeps the materials its slots show when the +export splits it: a slot linked to the object overrides the mesh data's material, as +an IFC import links a material to each object over a mesh shared by hundreds of them. +They need a real Blender (bmesh and the separate operator). Run them from the +repository root: + + blender --background --factory-startup --python-exit-code 1 \ + --python scripts/tests/blender/material_slot_checks.py +""" + +import sys +import unittest +from pathlib import Path + +import bpy + +SCRIPT_DIR = Path(__file__).resolve().parents[2] +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import untoldexplorer as u + + +def material(name: str): + created = bpy.data.materials.new(name) + created.use_nodes = True + return created + + +def two_material_cube(name: str, mesh=None): + """A cube whose faces use slot 0 and slot 1 half and half; with `mesh`, a linked + duplicate of that cube (one mesh datablock, its own object).""" + if mesh is None: + bpy.ops.mesh.primitive_cube_add() + cube = bpy.context.active_object + mesh = cube.data + bpy.data.objects.remove(cube) + mesh.materials.append(material(f"{name} data 0")) + mesh.materials.append(material(f"{name} data 1")) + for index, polygon in enumerate(mesh.polygons): + polygon.material_index = 0 if index < 3 else 1 + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + return obj + + +def override_slot(obj, index: int, name: str): + """Links slot `index` to the object and gives it its own material.""" + slot = obj.material_slots[index] + slot.link = "OBJECT" + slot.material = material(name) + return slot.material + + +def materials_by_source(fragments) -> dict[str, set[str]]: + result: dict[str, set[str]] = {} + for fragment in fragments: + source = fragment.get(u.UNTOLD_MATERIAL_SPLIT_SOURCE_PROP) or fragment.name + result.setdefault(source, set()).add(u.mesh_object_material(fragment).name) + return result + + +class MaterialSlotChecks(unittest.TestCase): + def tearDown(self) -> None: + u.cleanup_temporary_export_objects([]) + for collection in (bpy.data.objects, bpy.data.meshes, bpy.data.armatures, bpy.data.materials): + for block in list(collection): + collection.remove(block) + + def test_a_slot_linked_to_the_object_is_what_its_fragment_exports(self) -> None: + cube = two_material_cube("Chair") + override_slot(cube, 1, "Chair wood") + fragments = u.split_blender_objects_by_material([cube]) + self.assertEqual(len(fragments), 2) + self.assertEqual(materials_by_source(fragments), {"Chair": {"Chair data 0", "Chair wood"}}) + + def test_linked_duplicates_keep_their_own_object_materials(self) -> None: + first = two_material_cube("First") + second = two_material_cube("Second", mesh=first.data) + override_slot(first, 1, "First wood") + override_slot(second, 1, "Second steel") + fragments = u.split_blender_objects_by_material([first, second]) + self.assertEqual(len(fragments), 4) + self.assertEqual( + materials_by_source(fragments), + {"First": {"First data 0", "First wood"}, "Second": {"First data 0", "Second steel"}}, + ) + + def test_the_export_carries_the_object_material(self) -> None: + cube = two_material_cube("Chair") + override_slot(cube, 1, "Chair wood") + prepared = u.prepare_export_objects_from_blender_objects([cube]) + nodes = u.extract_nodes_from_objects(prepared, Path(bpy.app.tempdir) / "scene.blend", validate=True) + exported = {node.mesh.material.name for node in nodes if node.mesh is not None} + self.assertEqual(exported, {"Chair data 0", "Chair wood"}) + + def test_a_rigged_object_keeps_the_object_material_of_each_piece(self) -> None: + cube = two_material_cube("Rigged") + override_slot(cube, 1, "Rigged wood") + armature = bpy.data.objects.new("Rig", bpy.data.armatures.new("Rig")) + bpy.context.scene.collection.objects.link(armature) + modifier = cube.modifiers.new("Armature", "ARMATURE") + modifier.object = armature + fragments = u.split_blender_objects_by_material([cube]) + self.assertEqual(len(fragments), 2) + self.assertEqual(materials_by_source(fragments), {"Rigged": {"Rigged data 0", "Rigged wood"}}) + + +if __name__ == "__main__": + argv = [sys.argv[0]] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []) + unittest.main(argv=argv, exit=True) diff --git a/scripts/untoldexplorer.py b/scripts/untoldexplorer.py index a047db286..918ff6609 100644 --- a/scripts/untoldexplorer.py +++ b/scripts/untoldexplorer.py @@ -3004,21 +3004,17 @@ def mesh_object_material(mesh_object: object) -> Optional[object]: """The material the mesh's faces use. Each exported mesh has one (multi-material objects are split first), but it need not sit in the first slot: an object can use only its second material, which used to export as the first one. Object-linked - slots are honoured through material_slots.""" + slots are honoured through _slot_material, the one place that resolves a slot.""" data = getattr(mesh_object, "data", None) polygons = getattr(data, "polygons", None) try: index = int(polygons[0].material_index) if polygons is not None and len(polygons) > 0 else 0 except (TypeError, IndexError, AttributeError): index = 0 - slots = getattr(mesh_object, "material_slots", None) - if slots is not None and index < len(slots): - material = getattr(slots[index], "material", None) - if material is not None: - return material - materials = getattr(data, "materials", []) - if materials and index < len(materials) and materials[index] is not None: - return materials[index] + material = _slot_material(mesh_object, data, index) + if material is not None: + return material + materials = getattr(data, "materials", None) or [] return materials[0] if materials and materials[0] is not None else None @@ -7488,19 +7484,34 @@ def _separate_rigged_object_by_material(obj: object) -> list[object]: piece_used = {p.material_index for p in piece.data.polygons} if piece_used: used_index = piece_used.pop() - material = ( - piece.data.materials[used_index] - if used_index < len(piece.data.materials) - else None - ) + material = _slot_material(piece, piece.data, used_index) piece.data.materials.clear() if material is not None: piece.data.materials.append(material) + # The piece keeps the object's slot links of its source; its one material + # now sits in the mesh data, so the slot must read from there. + for slot in getattr(piece, "material_slots", []): + if getattr(slot, "link", "DATA") != "DATA": + slot.link = "DATA" for polygon in piece.data.polygons: polygon.material_index = 0 return pieces +def _slot_material(obj: object, mesh: object, index: int) -> Optional[object]: + """The material of slot `index` as the object shows it. A slot linked to the object + overrides the material the mesh data holds in that slot (an IFC import links a + material to each object over a mesh shared by hundreds of them), so the object's + slots are asked first and the mesh data only answers for what they do not cover.""" + slots = getattr(obj, "material_slots", None) + if slots is not None and index < len(slots): + material = getattr(slots[index], "material", None) + if material is not None: + return material + materials = getattr(mesh, "materials", None) or [] + return materials[index] if index < len(materials) else None + + def split_blender_objects_by_material(objects: list[object]) -> list[object]: """Split any Blender mesh object that assigns multiple materials across its faces into separate single-material objects. @@ -7592,7 +7603,7 @@ def split_blender_objects_by_material(objects: list[object]) -> list[object]: new_mesh = bpy.data.meshes.new(f"{obj.data.name}_mat{mat_idx}") bm.to_mesh(new_mesh) new_mesh.update() - mat = mesh.materials[mat_idx] if mat_idx < len(mesh.materials) else None + mat = _slot_material(obj, mesh, mat_idx) if mat: new_mesh.materials.append(mat) for p in new_mesh.polygons: From 45823f679ad84412abd922615ab90a55bdcee87b Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 8 Oct 2026 18:45:56 +0200 Subject: [PATCH 03/11] [Bugfix] Test occlusion from the camera the depth pyramid was built with (#1323) The occlusion test drops the entities that the depth pyramid of the previous frame hides. It projected this frame's entities with this frame's camera into a pyramid rendered from the previous frame's camera, with a fixed depth bias as the only allowance for the difference. Whenever a step uncovered what a near surface had covered (rising above the ground, backing away from a wall, strafing past a trunk), far entities landed on texels that still held the near depth and were dropped until the view settled: on a large site the trees vanished while the camera flew and came back when it stopped. The pyramid now records the camera it was built from (RenderInfo.hzbFrame: the view-projection with the scene root and the camera's position; in stereo the eye rendered last), and the test projects with that camera. An entity seen from both places is tested where the pyramid holds its own depth, however the camera moved in between; an entity the old camera could not see is kept; what a step uncovers from behind a near surface shows one frame late. After a step beyond HZBOcclusionCulling.maxCameraStep (0.5 units by default, setRendering(.occlusionCullingMaxCameraStep(_:))) the pyramid says too little about the frame and the test is skipped: the frame draws what the frustum keeps. The kernel and the bias are unchanged; the unused hzbPyramidOverride parameter is gone. HZBOcclusionReprojectionTests: a box seen over a near wall from both places stays visible when the camera rises (dropped before this change), a box behind the wall stays hidden across a small step, the test is skipped for the frame after a jump and runs again from the new place, the camera of a view matrix, the pyramid's record, the setting. CullingTest's direct runs of the pass set the pyramid's camera. Co-authored-by: Javier Segura --- .../Renderer/RenderResources.swift | 17 ++ .../UntoldEngine/Systems/CullingSystem.swift | 94 +++++++--- .../Utils/EngineSettingsAPI.swift | 10 + .../UntoldEngineRenderTests/CullingTest.swift | 23 ++- .../HZBOcclusionReprojectionTests.swift | 171 ++++++++++++++++++ docs/API/UsingEngineAPI.md | 10 + docs/Architecture/renderingSystem.md | 2 + 7 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 Tests/UntoldEngineRenderTests/HZBOcclusionReprojectionTests.swift diff --git a/Sources/UntoldEngine/Renderer/RenderResources.swift b/Sources/UntoldEngine/Renderer/RenderResources.swift index f4b532a7b..51b8dbd44 100644 --- a/Sources/UntoldEngine/Renderer/RenderResources.swift +++ b/Sources/UntoldEngine/Renderer/RenderResources.swift @@ -55,6 +55,10 @@ public struct RenderInfo { public var colorPipeline: ColorPipelineConfig = .standard(presentFormat: .bgra8Unorm_srgb) public var hzbMipCount: Int = 0 public var hzbIsValid: Bool = false + /// The camera the HZB pyramid was built from: the view-projection of the frame whose + /// depth it holds and where that camera stood. The occlusion test of the next frame + /// projects with it (see `executeHZBOcclusionCulling`). Nil until a pyramid is built. + public var hzbFrame: HZBPyramidFrame? /// Whether the Gaussian pass encoded this frame, so `gaussianColorMap` holds this frame's /// coverage: false on the simulator and on a frame the pass skipped, when the anti-aliasing /// passes must not read the map. @@ -297,3 +301,16 @@ public struct ScenePickingGeometryMetadata { self.submeshIndex = submeshIndex } } + +/// The camera a depth pyramid was built from. +public struct HZBPyramidFrame: Sendable { + /// The view-projection of the frame whose depth the pyramid holds, scene root included. + public var viewProjection: simd_float4x4 + /// Where that frame's camera stood, in world space. + public var cameraPosition: simd_float3 + + public init(viewProjection: simd_float4x4, cameraPosition: simd_float3) { + self.viewProjection = viewProjection + self.cameraPosition = cameraPosition + } +} diff --git a/Sources/UntoldEngine/Systems/CullingSystem.swift b/Sources/UntoldEngine/Systems/CullingSystem.swift index ed02366c8..86eb0a96e 100644 --- a/Sources/UntoldEngine/Systems/CullingSystem.swift +++ b/Sources/UntoldEngine/Systems/CullingSystem.swift @@ -468,6 +468,7 @@ public func buildHZBDepthPyramid(_ commandBuffer: MTLCommandBuffer, eyeIndex: In } renderInfo.hzbIsValid = true + renderInfo.hzbFrame = HZBPyramidFrame.current() let selectedMipLevel = min(max(renderInfo.hzbDebugMipLevel, 0), textureResources.hzbMipViews.count - 1) renderInfo.hzbDebugMipLevel = selectedMipLevel @@ -482,23 +483,35 @@ public func buildHZBDepthPyramid(_ commandBuffer: MTLCommandBuffer, eyeIndex: In ) } +/// Drops from the frustum's candidates the entities the depth pyramid of the previous frame +/// hides. The pyramid is seen from that frame's camera (`renderInfo.hzbFrame`), and so are +/// the candidates: an entity seen in both frames is tested where the pyramid holds its own +/// depth, however the camera moved in between; what a step uncovers from behind a near +/// surface shows one frame late; an entity the old camera did not see is kept. After a step +/// beyond `HZBOcclusionCulling.maxCameraStep` the pyramid says too little about this frame +/// and the test is skipped. Returns whether it ran. @discardableResult func executeHZBOcclusionCulling( _ commandBuffer: MTLCommandBuffer, - viewProjection: simd_float4x4, + cameraPosition: simd_float3, dispatchCount: Int, inputVisibilityBuffer: MTLBuffer, inputVisibleCountBuffer: MTLBuffer, outputVisibilityBuffer: MTLBuffer, - outputVisibleCountBuffer: MTLBuffer, - hzbPyramidOverride: MTLTexture? = nil + outputVisibleCountBuffer: MTLBuffer ) -> Bool { - if hzbPyramidOverride == nil && renderInfo.hzbIsValid == false { + if renderInfo.hzbIsValid == false { return false } - guard let hzbDepthPyramid = hzbPyramidOverride ?? textureResources.hzbDepthPyramid else { - if hzbPyramidOverride == nil { renderInfo.hzbIsValid = false } + guard let hzbDepthPyramid = textureResources.hzbDepthPyramid else { + renderInfo.hzbIsValid = false + return false + } + + guard let pyramidFrame = renderInfo.hzbFrame, + simd_distance(cameraPosition, pyramidFrame.cameraPosition) <= HZBOcclusionCulling.maxCameraStep + else { return false } @@ -518,9 +531,9 @@ func executeHZBOcclusionCulling( var viewport = simd_float2(renderInfo.viewPort.x, renderInfo.viewPort.y) var mipCount = UInt32(max(0, renderInfo.hzbMipCount)) var reverseZFlag: UInt32 = renderInfo.reverseZEnabled ? 1 : 0 - var viewProjectionMatrix = viewProjection - // HZB is temporal, so use a conservative bias to tolerate one-frame camera - // and projection drift before declaring an object fully occluded. + var viewProjectionMatrix = pyramidFrame.viewProjection + // The pyramid and the test see the scene from the same camera; the bias covers the + // drift of the depths themselves before an object is called occluded. var occlusionBias: Float = 0.02 let computeEncoder: MTLComputeCommandEncoder = commandBuffer.makeComputeCommandEncoder()! @@ -777,18 +790,14 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { let eye1CountBuf = eye1CountTriple.bufferForWrite(frame: submitFrameIndex) let eye1VisBuf = eye1VisTriple.bufferForWrite(frame: submitFrameIndex) - // Use the shared mono HZB pyramid for both eyes. - // The per-eye pyramids (hzbDepthPyramidEye) are allocated but never built because - // buildHZBDepthPyramid is called without eyeIndex in executeXRSystemPass, which - // means only the mono pyramid (textureResources.hzbDepthPyramid) is populated each - // frame. Passing hzbDepthPyramidEye as an override bypasses the hzbIsValid guard - // and runs the GPU shader against an uninitialized texture, producing no real - // occlusion. Omitting the override lets executeHZBOcclusionCulling fall back to - // the mono pyramid gated by renderInfo.hzbIsValid, which is the intended behaviour - // described in the comment above buildHZBDepthPyramid in UntoldEngineXR.swift. + // Both eyes are tested against the one pyramid, built from the last eye rendered and + // seen from that eye's camera (buildHZBDepthPyramid records it): the eyes stand a + // few centimetres apart, the frames a camera step. The per-eye pyramids + // (hzbDepthPyramidEye) are allocated, but nothing renders the per-eye depth they + // would be built from. let ran0 = executeHZBOcclusionCulling( commandBuffer, - viewProjection: renderInfo.xrEye0ViewProjection, + cameraPosition: eyePosition(ofView: SceneRootTransform.shared.effectiveViewMatrix(renderInfo.xrEye0View)), dispatchCount: count, inputVisibilityBuffer: candidateVisibilityBuffer, inputVisibleCountBuffer: candidateVisibleCountBuffer, @@ -798,7 +807,7 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { let ran1 = executeHZBOcclusionCulling( commandBuffer, - viewProjection: renderInfo.xrEye1ViewProjection, + cameraPosition: eyePosition(ofView: SceneRootTransform.shared.effectiveViewMatrix(renderInfo.xrEye1View)), dispatchCount: count, inputVisibilityBuffer: candidateVisibilityBuffer, inputVisibleCountBuffer: candidateVisibleCountBuffer, @@ -844,7 +853,7 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { // Mono path (macOS / non-stereo) let didRunOcclusion = executeHZBOcclusionCulling( commandBuffer, - viewProjection: viewProjection, + cameraPosition: eyePosition(ofView: effectiveViewMatrix), dispatchCount: count, inputVisibilityBuffer: candidateVisibilityBuffer, inputVisibleCountBuffer: candidateVisibleCountBuffer, @@ -1165,7 +1174,7 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) { let didRunOcclusion = executeHZBOcclusionCulling( commandBuffer, - viewProjection: viewProjection, + cameraPosition: eyePosition(ofView: effectiveViewMatrix), dispatchCount: count, inputVisibilityBuffer: candidateVisibilityBuffer, inputVisibleCountBuffer: candidateVisibleCountBuffer, @@ -1226,3 +1235,44 @@ func isAABBInFrustum(_ frustum: Frustum, min aabbMin: simd_float3, max aabbMax: func isAABBInFrustum(center: simd_float3, halfExtent: simd_float3, frustum: Frustum) -> Bool { isAABBInFrustum(frustum, min: center - halfExtent, max: center + halfExtent) } + +// MARK: - The occlusion test's camera + +/// The occlusion test against the depth pyramid of the previous frame. +public enum HZBOcclusionCulling { + /// The farthest the camera may have moved since the pyramid was built for the test to + /// run. The test projects this frame's entities with the pyramid's own camera, so the + /// motion itself costs nothing: an entity seen in both frames is tested where the + /// pyramid saw it. What a step uncovers from behind a near surface shows one frame + /// late, and past this distance (a jump, a fast flight) the frame skips the test and + /// draws what the frustum keeps. In scene units; 0.5 by default. Set it with + /// `setRendering(.occlusionCullingMaxCameraStep(_:))`. + nonisolated(unsafe) static var maxCameraStep: Float = 0.5 +} + +/// The farthest the camera may move between two frames for the occlusion test to run. +public func getOcclusionCullingMaxCameraStep() -> Float { + HZBOcclusionCulling.maxCameraStep +} + +/// Where the camera of a view matrix stands, in the space the matrix views from. +func eyePosition(ofView view: simd_float4x4) -> simd_float3 { + simd_make_float3(simd_inverse(view).columns.3) +} + +extension HZBPyramidFrame { + /// The camera of the frame being drawn, which the depth just rendered was projected + /// with: the active camera with the scene root, and the projection in use. In stereo + /// it is the eye rendered last, whose view and projection `renderXR` left in the camera + /// component and in `renderInfo.perspectiveSpace`. + static func current() -> HZBPyramidFrame? { + guard let camera = CameraSystem.shared.activeCamera, + let cameraComponent = scene.get(component: CameraComponent.self, for: camera) + else { return nil } + let view = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) + return HZBPyramidFrame( + viewProjection: simd_mul(renderInfo.perspectiveSpace, view), + cameraPosition: eyePosition(ofView: view) + ) + } +} diff --git a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift index 8af652309..dea53a774 100644 --- a/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift +++ b/Sources/UntoldEngine/Utils/EngineSettingsAPI.swift @@ -78,6 +78,14 @@ public enum RenderingProperty: Sendable { /// long thin object goes only once its whole length is that small. The default is 1; /// 0 draws everything. See `getSmallObjectCullingPixels()` to read the current value. case smallObjectCulling(pixels: Float) + /// The farthest the camera may have moved since the depth pyramid of the previous + /// frame was built for the occlusion test to run on this frame. The test projects + /// objects with the pyramid's own camera, so the motion itself costs nothing; what a + /// step uncovers from behind a near surface shows one frame late, and past this + /// distance (a jump, a fast flight) the frame skips the test and draws what the + /// frustum keeps. In scene units; the default is 0.5. See + /// `getOcclusionCullingMaxCameraStep()` to read the current value. + case occlusionCullingMaxCameraStep(Float) } public enum RenderingToggle: Sendable { @@ -138,6 +146,8 @@ public func setRendering(_ property: RenderingProperty) { RenderPasses.maxShadowCastingDistance = max(distance, 0.001) case let .smallObjectCulling(pixels): SmallObjectCulling.minimumPixels = pixels.isFinite ? max(pixels, 0) : 0 + case let .occlusionCullingMaxCameraStep(step): + HZBOcclusionCulling.maxCameraStep = step.isFinite ? max(step, 0) : 0 } } diff --git a/Tests/UntoldEngineRenderTests/CullingTest.swift b/Tests/UntoldEngineRenderTests/CullingTest.swift index 7bb85ee80..cb681dffa 100644 --- a/Tests/UntoldEngineRenderTests/CullingTest.swift +++ b/Tests/UntoldEngineRenderTests/CullingTest.swift @@ -334,11 +334,13 @@ final class CullingTest: BaseRenderSetup { let originalHZBTexture = textureResources.hzbDepthPyramid let originalHZBMipCount = renderInfo.hzbMipCount let originalHZBValid = renderInfo.hzbIsValid + let originalHZBFrame = renderInfo.hzbFrame let originalViewport = renderInfo.viewPort defer { textureResources.hzbDepthPyramid = originalHZBTexture renderInfo.hzbMipCount = originalHZBMipCount renderInfo.hzbIsValid = originalHZBValid + renderInfo.hzbFrame = originalHZBFrame renderInfo.viewPort = originalViewport } @@ -348,6 +350,8 @@ final class CullingTest: BaseRenderSetup { textureResources.hzbDepthPyramid = makeHZBTestTexture(depthValue: clearDepth) renderInfo.hzbMipCount = 1 renderInfo.hzbIsValid = true + // The pyramid as seen from the origin, with no projection: the test projects with it. + renderInfo.hzbFrame = HZBPyramidFrame(viewProjection: matrix_identity_float4x4, cameraPosition: .zero) renderInfo.viewPort = simd_float2(1920, 1080) var candidateCount: UInt32 = 1 @@ -361,7 +365,7 @@ final class CullingTest: BaseRenderSetup { let commandBuffer = try XCTUnwrap(renderInfo.commandQueue.makeCommandBuffer()) let didRun = executeHZBOcclusionCulling( commandBuffer, - viewProjection: matrix_identity_float4x4, + cameraPosition: .zero, dispatchCount: 1, inputVisibilityBuffer: inputVisibilityBuffer, inputVisibleCountBuffer: inputCountBuffer, @@ -384,11 +388,13 @@ final class CullingTest: BaseRenderSetup { let originalHZBTexture = textureResources.hzbDepthPyramid let originalHZBMipCount = renderInfo.hzbMipCount let originalHZBValid = renderInfo.hzbIsValid + let originalHZBFrame = renderInfo.hzbFrame let originalViewport = renderInfo.viewPort defer { textureResources.hzbDepthPyramid = originalHZBTexture renderInfo.hzbMipCount = originalHZBMipCount renderInfo.hzbIsValid = originalHZBValid + renderInfo.hzbFrame = originalHZBFrame renderInfo.viewPort = originalViewport } @@ -398,6 +404,8 @@ final class CullingTest: BaseRenderSetup { textureResources.hzbDepthPyramid = makeHZBTestTexture(depthValue: occluderDepth) renderInfo.hzbMipCount = 1 renderInfo.hzbIsValid = true + // The pyramid as seen from the origin, with no projection: the test projects with it. + renderInfo.hzbFrame = HZBPyramidFrame(viewProjection: matrix_identity_float4x4, cameraPosition: .zero) renderInfo.viewPort = simd_float2(1920, 1080) var candidateCount: UInt32 = 1 @@ -411,7 +419,7 @@ final class CullingTest: BaseRenderSetup { let commandBuffer = try XCTUnwrap(renderInfo.commandQueue.makeCommandBuffer()) let didRun = executeHZBOcclusionCulling( commandBuffer, - viewProjection: matrix_identity_float4x4, + cameraPosition: .zero, dispatchCount: 1, inputVisibilityBuffer: inputVisibilityBuffer, inputVisibleCountBuffer: inputCountBuffer, @@ -430,11 +438,13 @@ final class CullingTest: BaseRenderSetup { let originalHZBTexture = textureResources.hzbDepthPyramid let originalHZBMipCount = renderInfo.hzbMipCount let originalHZBValid = renderInfo.hzbIsValid + let originalHZBFrame = renderInfo.hzbFrame let originalViewport = renderInfo.viewPort defer { textureResources.hzbDepthPyramid = originalHZBTexture renderInfo.hzbMipCount = originalHZBMipCount renderInfo.hzbIsValid = originalHZBValid + renderInfo.hzbFrame = originalHZBFrame renderInfo.viewPort = originalViewport } @@ -455,7 +465,7 @@ final class CullingTest: BaseRenderSetup { let commandBuffer = try XCTUnwrap(renderInfo.commandQueue.makeCommandBuffer()) let didRun = executeHZBOcclusionCulling( commandBuffer, - viewProjection: matrix_identity_float4x4, + cameraPosition: .zero, dispatchCount: 1, inputVisibilityBuffer: inputVisibilityBuffer, inputVisibleCountBuffer: inputCountBuffer, @@ -474,17 +484,21 @@ final class CullingTest: BaseRenderSetup { let originalHZBTexture = textureResources.hzbDepthPyramid let originalHZBMipCount = renderInfo.hzbMipCount let originalHZBValid = renderInfo.hzbIsValid + let originalHZBFrame = renderInfo.hzbFrame let originalViewport = renderInfo.viewPort defer { textureResources.hzbDepthPyramid = originalHZBTexture renderInfo.hzbMipCount = originalHZBMipCount renderInfo.hzbIsValid = originalHZBValid + renderInfo.hzbFrame = originalHZBFrame renderInfo.viewPort = originalViewport } textureResources.hzbDepthPyramid = makeHZBTestTexture(depthValue: 1.0) renderInfo.hzbMipCount = 1 renderInfo.hzbIsValid = true + // The pyramid as seen from the origin, with no projection: the test projects with it. + renderInfo.hzbFrame = HZBPyramidFrame(viewProjection: matrix_identity_float4x4, cameraPosition: .zero) renderInfo.viewPort = simd_float2(1920, 1080) let localCenter = simd_float3(0, 0, 0.6) @@ -507,10 +521,11 @@ final class CullingTest: BaseRenderSetup { let outputVisibilityBuffer = try XCTUnwrap(renderInfo.device.makeBuffer(length: MemoryLayout.stride)) memset(outputCountBuffer.contents(), 0, MemoryLayout.stride) + renderInfo.hzbFrame = HZBPyramidFrame(viewProjection: viewProjection, cameraPosition: .zero) let commandBuffer = try XCTUnwrap(renderInfo.commandQueue.makeCommandBuffer()) let didRun = executeHZBOcclusionCulling( commandBuffer, - viewProjection: viewProjection, + cameraPosition: .zero, dispatchCount: 1, inputVisibilityBuffer: inputVisibilityBuffer, inputVisibleCountBuffer: inputCountBuffer, diff --git a/Tests/UntoldEngineRenderTests/HZBOcclusionReprojectionTests.swift b/Tests/UntoldEngineRenderTests/HZBOcclusionReprojectionTests.swift new file mode 100644 index 000000000..64146fd51 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/HZBOcclusionReprojectionTests.swift @@ -0,0 +1,171 @@ +// +// HZBOcclusionReprojectionTests.swift +// UntoldEngine +// +// The occlusion test runs against the depth pyramid of the frame before, and sees the +// scene from that frame's camera: what the camera's movement does to the test. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +final class HZBOcclusionReprojectionTests: BaseRenderSetup { + private var camera: EntityID = 0 + private var savedStep: Float = 0.5 + private var savedPixels: Float = 1 + + override func setUp() async throws { + try await super.setUp() + savedStep = HZBOcclusionCulling.maxCameraStep + HZBOcclusionCulling.maxCameraStep = 0.5 + savedPixels = SmallObjectCulling.minimumPixels + SmallObjectCulling.minimumPixels = 0 + } + + override func tearDown() async throws { + HZBOcclusionCulling.maxCameraStep = savedStep + SmallObjectCulling.minimumPixels = savedPixels + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() { + camera = createEntity() + createGameCamera(entityId: camera) + CameraSystem.shared.activeCamera = camera + look(from: simd_float3(0, 1, 0)) + } + + // MARK: - Helpers + + /// Looks along -z from `eye`. + private func look(from eye: simd_float3) { + cameraLookAt(entityId: camera, eye: eye, target: eye + simd_float3(0, 0, -1), up: simd_float3(0, 1, 0)) + } + + private func makeBox(size: simd_float3, at position: simd_float3) -> EntityID { + let entity = createEntity() + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createCube(extent: 1.0), assetName: "Box") + scaleTo(entityId: entity, scale: size) + translateTo(entityId: entity, position: position) + return entity + } + + /// One frame. Its cull runs against the pyramid of the frame before and its depth becomes + /// the next pyramid. The cull's counts are in `HZBDebugMonitor` once the frame's command + /// buffer has completed; the visible list itself reaches `visibleEntityIds` a frame or two + /// later, through the triple buffer, so the tests read the monitor. + private func drawFrame() { + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + } + + /// Enough frames for the scene to be drawn and its depth to be the pyramid the last + /// frame's cull ran against: the first cull's list is drawn two frames later at most. + private func settle() { + for _ in 0 ..< 4 { + drawFrame() + } + } + + /// Whether the cull of the last frame ran the occlusion test. + private var lastCullUsedOcclusion: Bool { + HZBDebugMonitor.shared.stats.usedHZBThisFrame + } + + /// How many of the last cull's candidates the occlusion test dropped. In these scenes the + /// wall stands in front of everything, so only the small box can ever be dropped. + private var lastCullOccluded: Int { + HZBDebugMonitor.shared.stats.occludedCount + } + + /// A wall 3 units ahead of the camera, 1.5 tall and wider than the view: the occluder. + private func makeWall() -> EntityID { + makeBox(size: simd_float3(20, 1.5, 0.2), at: simd_float3(0, 0.75, -3)) + } + + // MARK: - The camera's movement + + func testAnObjectSeenFromBothPlacesStaysVisibleWhenTheCameraRises() { + setRendering(.occlusionCullingMaxCameraStep(5)) + _ = makeWall() + // A small box 80 units out, high enough to show over the wall's top from 1 unit up. + _ = makeBox(size: simd_float3(0.5, 0.5, 0.5), at: simd_float3(0, 15.8, -80)) + settle() + XCTAssertTrue(lastCullUsedOcclusion) + XCTAssertEqual(lastCullOccluded, 0, "over the wall's top from 1 unit up") + + // Three units higher the box still shows over the wall, lower on screen: where the + // pyramid of the frame before holds the wall, 3 units from the camera. + look(from: simd_float3(0, 4, 0)) + drawFrame() + XCTAssertTrue(lastCullUsedOcclusion) + XCTAssertEqual(lastCullOccluded, 0, "tested where the old camera saw it: over the wall") + } + + func testAnObjectBehindTheWallStaysHiddenAcrossASmallStep() { + _ = makeWall() + _ = makeBox(size: simd_float3(0.5, 0.5, 0.5), at: simd_float3(0, 0.5, -20)) + settle() + XCTAssertTrue(lastCullUsedOcclusion) + XCTAssertEqual(lastCullOccluded, 1, "behind the wall") + + look(from: simd_float3(0.2, 1, 0)) + drawFrame() + XCTAssertTrue(lastCullUsedOcclusion, "a step of 0.2 is within the default") + XCTAssertEqual(lastCullOccluded, 1, "still behind the wall") + } + + func testTheTestIsSkippedForTheFrameAfterAJump() { + _ = makeWall() + _ = makeBox(size: simd_float3(0.5, 0.5, 0.5), at: simd_float3(0, 0.5, -20)) + settle() + XCTAssertEqual(lastCullOccluded, 1) + + // Two units sideways: past the default step. That frame's cull keeps what the frustum keeps. + look(from: simd_float3(2, 1, 0)) + drawFrame() + XCTAssertFalse(lastCullUsedOcclusion, "the pyramid was built 2 units away") + XCTAssertEqual(lastCullOccluded, 0) + + // The frame after has the pyramid of the new place and hides the box again. + drawFrame() + XCTAssertTrue(lastCullUsedOcclusion) + XCTAssertEqual(lastCullOccluded, 1, "behind the wall from here too") + } + + func testTheCameraOfTheViewMatrixIsWhereItLooksFrom() throws { + look(from: simd_float3(3, 4, 5)) + let view = try XCTUnwrap(scene.get(component: CameraComponent.self, for: camera)).viewSpace + let position = eyePosition(ofView: view) + XCTAssertEqual(position.x, 3, accuracy: 1e-4) + XCTAssertEqual(position.y, 4, accuracy: 1e-4) + XCTAssertEqual(position.z, 5, accuracy: 1e-4) + } + + func testThePyramidRecordsTheCameraItWasBuiltFrom() { + look(from: simd_float3(1, 2, 3)) + drawFrame() + let frame = renderInfo.hzbFrame + XCTAssertNotNil(frame) + XCTAssertEqual(frame?.cameraPosition.x ?? 0, 1, accuracy: 1e-4) + XCTAssertEqual(frame?.cameraPosition.y ?? 0, 2, accuracy: 1e-4) + XCTAssertEqual(frame?.cameraPosition.z ?? 0, 3, accuracy: 1e-4) + } + + func testTheStepSettingRoundTrips() { + setRendering(.occlusionCullingMaxCameraStep(2)) + XCTAssertEqual(getOcclusionCullingMaxCameraStep(), 2) + setRendering(.occlusionCullingMaxCameraStep(-1)) + XCTAssertEqual(getOcclusionCullingMaxCameraStep(), 0, "no negative step") + setRendering(.occlusionCullingMaxCameraStep(.nan)) + XCTAssertEqual(getOcclusionCullingMaxCameraStep(), 0) + } +} diff --git a/docs/API/UsingEngineAPI.md b/docs/API/UsingEngineAPI.md index d31a038e4..7889c439f 100644 --- a/docs/API/UsingEngineAPI.md +++ b/docs/API/UsingEngineAPI.md @@ -72,6 +72,16 @@ setRendering(.smallObjectCulling(pixels: 2.0)) getSmallObjectCullingPixels() ``` +Occlusion culling tests each frame's objects against the depth of the frame before, seen from +that frame's camera, so a moving camera costs nothing by itself. After a jump, or a flight +faster than this many scene units a frame, the previous frame says too little and the test is +skipped for that frame: + +```swift +setRendering(.occlusionCullingMaxCameraStep(0.5)) +getOcclusionCullingMaxCameraStep() +``` + ## PostFX Use `setPostFX` for individual post-processing and SSAO settings: diff --git a/docs/Architecture/renderingSystem.md b/docs/Architecture/renderingSystem.md index 3e80d2b4a..d52727b71 100644 --- a/docs/Architecture/renderingSystem.md +++ b/docs/Architecture/renderingSystem.md @@ -386,6 +386,8 @@ After the render graph finishes, the stored opaque depth source captured by `hzb This is intentionally scheduled here, after the render graph and before `commit()`, so the HZB is built from the freshest depth available and ready for the next frame's culling compute dispatch. +**The camera of the pyramid.** The build records the camera the depth was rendered from (`renderInfo.hzbFrame`: the view-projection with the scene root, and the camera's position; in stereo the eye rendered last). The next frame's occlusion test (`executeHZBOcclusionCulling`, `hzbCullVisibleEntities`) projects the entities with that camera, not with its own: an entity seen from both places is tested where the pyramid holds its own depth, however the camera moved in between, and an entity the old camera could not see at all is kept. What a step uncovers from behind a near surface shows one frame late. When the camera moved more than `HZBOcclusionCulling.maxCameraStep` since the build (0.5 units by default, `setRendering(.occlusionCullingMaxCameraStep(_:))`), the pyramid says too little about the frame and the test is skipped: the frame draws what the frustum keeps. Tested with the frame's own camera, as before, a step that uncovers something near, such as rising above the ground or backing away from a wall, put far entities over texels that still held the near depth and dropped them until the view settled. + --- ## Step 7: Present and Commit From 8b695d6c13f3e33ac270f38ece6a8dcb93e56fd3 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 8 Oct 2026 21:04:44 +0200 Subject: [PATCH 04/11] [Performance] The render passes read the scene through a snapshot (#1321) * [Performance] A snapshot of the scene for the render passes A pass asks the same few things about thousands of entities: is the entity still there, which of a dozen components does it have, where are its render and transform components. Asked through `scene`, each question takes the scene lock, copies the scene and looks a component id up. RenderSceneSnapshot reads the scene once, looks up the ids and the storage of the components the passes use once, and answers from what it took: - entity(_:) says whether an id is a live entity and gives its traits (RenderEntityTraits, from the entity's component mask) - drawComponents(of:) and the other accessors hand out the scene's own component objects, so their values are always the current ones - forEachEntity(with:) walks the entities that have a set of components, in the order of their indices - SceneChannelRenderModeMemo asks for the render mode of a channel set only when it differs from the one of the entity before A snapshot lives for one pass. Nothing uses it yet. * [Performance] The cull and the shadow passes read the scene through a snapshot The culling gather, the fit of the shadow cascades and the choice of the shadow casters walked every render entity through `scene`: about a dozen locked lookups per entity, per pass and per cascade. They now build a RenderSceneSnapshot when they start. - the two culling gathers walk the snapshot, so the list sent to the GPU cull follows the entity indices instead of the order of a hash set - the casters of a cascade, of the spot light and of the point light are chosen from the snapshot and keep their components for the draw loop - the shadow draw loops read the camera position and the projection once - a mesh's vertex streams are bound from the deformation component and the skeleton flag the pass already has, and MTKMesh.vertexBuffers, which builds an array on every read, is read once per mesh What is drawn does not change. * [Performance] The model, transparency and wireframe passes read the scene through a snapshot For every visible entity the opaque passes asked `scene` about fifteen things before the first draw, and five more for every draw (two fades, the ghost opacity, the deformation buffers, the skeleton), besides settings that sit behind a lock of their own. - an entity's draws start from one read of the snapshot: its traits answer what it is (camera, light, gizmo, batched, fading) and its components come from the storage the snapshot took - what every draw of a pass shares is read when the pass starts: view and projection, camera position, parallax quality, the debug colour switches - what every draw of an entity shares is read once per entity: its fades, its deformation buffers, its opacity on a ghost channel - an entity whose LOD is not fading draws its meshes without building the list of fade draws - the wireframe and occluder shell filters and the tile statistics reject an entity from its traits What is drawn does not change: the reference images score the same, and new render tests pin the per-entity decisions (hidden and wireframe channels, ids that are gone, tile and mesh fades). On a site of 21,576 render entities (release build, 1920x1080, M4 Max), with the two commits before this one: culling gather 7.5-8.7 ms to 1.5-1.7 ms, encoding 69-117 ms to 17-28 ms, frame 93-137 ms to 30-46 ms. * [Performance] The render entity traits no longer declare Sendable SwiftFormat 0.60.1 (the version CI runs) rejects an explicit Sendable on a non-public struct, and an internal option set of one UInt32 is Sendable by inference anyway. --------- Co-authored-by: Javier Segura --- .../UntoldEngine/Renderer/RenderPasses.swift | 582 ++++++++++-------- .../Renderer/RenderSceneSnapshot.swift | 281 +++++++++ .../Renderer/RenderVertexStreamBinding.swift | 45 +- .../UntoldEngine/Renderer/UntoldEngine.swift | 12 +- .../UntoldEngine/Systems/CullingSystem.swift | 69 +-- .../UntoldEngine/Systems/ShadowSystem.swift | 45 +- .../RenderPassEntityStateTests.swift | 257 ++++++++ .../RenderSceneSnapshotTests.swift | 355 +++++++++++ .../ShadowCascadeTests.swift | 4 +- .../ShadowDistanceCullingTests.swift | 2 +- docs/Architecture/renderingSystem.md | 34 +- 11 files changed, 1338 insertions(+), 348 deletions(-) create mode 100644 Sources/UntoldEngine/Renderer/RenderSceneSnapshot.swift create mode 100644 Tests/UntoldEngineRenderTests/RenderPassEntityStateTests.swift create mode 100644 Tests/UntoldEngineTests/RenderSceneSnapshotTests.swift diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index 409984810..b522ae85c 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -16,7 +16,7 @@ import MetalKit public enum RenderPasses { /// Count of unbatched shadow-caster entities from the most recent frame. - /// Updated by shadowCasterEntityIds (main thread); read by the streaming heartbeat (main thread). + /// Updated by shadowCasters(for:in:) (main thread); read by the streaming heartbeat (main thread). public nonisolated(unsafe) static var lastShadowCasterCount: Int = 0 public typealias RenderPassExecution = @Sendable (MTLCommandBuffer) -> Void @@ -265,16 +265,6 @@ public enum RenderPasses { return lodDebugPalette[clamped % lodDebugPalette.count] } - @inline(__always) - private static func passthroughGhostAlpha(for entityId: EntityID) -> Float { - guard renderInfo.immersionStyle == .mixed, - let opacity = passthroughGhostOpacity(for: getEntitySceneChannels(entityId: entityId)) - else { - return 1.0 - } - return opacity - } - @inline(__always) private static func passthroughGhostAlpha(for batchGroup: BatchGroup) -> Float { guard renderInfo.immersionStyle == .mixed, @@ -285,32 +275,56 @@ public enum RenderPasses { return opacity } - @inline(__always) - private static func isEntityInActiveLODFade(_ entityId: EntityID) -> Bool { - guard LODConfig.shared.enableFadeTransitions, - let lod = scene.get(component: LODComponent.self, for: entityId) - else { return false } - return lod.previousLOD != nil - } + /// What the draws of an opaque geometry pass share, read once when the pass starts. + private struct OpaquePassState { + let sceneSnapshot = RenderSceneSnapshot() + let viewMatrix: simd_float4x4 + let cameraPosition: simd_float3 + let projectionMatrix = renderInfo.perspectiveSpace + let pomQuality = RenderPasses.currentPOMQualityUniform() + let batchingEnabled = BatchingSystem.shared.isEnabled() + let lodFadesEnabled = LODConfig.shared.enableFadeTransitions + /// Ghost channels are drawn see-through only where the real world shows behind. + let ghostsInPassthrough = renderInfo.immersionStyle == .mixed + let colorsByLOD = SpatialDebugVisualization.shared.colorRenderablesByLOD + let colorsByStreamingTier = SpatialDebugVisualization.shared.colorRenderablesByStreamingTier - @inline(__always) - private static func isEntityInActiveTileRepresentationFade(_ entityId: EntityID) -> Bool { - scene.get(component: TileRepresentationFadeComponent.self, for: entityId) != nil + init(cameraComponent: CameraComponent) { + viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) + cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + } + + /// The entity's LOD component while LOD fades are on; nil otherwise, and for an + /// entity without levels. + @inline(__always) + func fadingLOD(of entity: RenderSceneSnapshot.Entity) -> LODComponent? { + lodFadesEnabled ? sceneSnapshot.lod(of: entity) : nil + } + + /// The opacity of an entity on a ghost channel, 1 for any other. + @inline(__always) + func passthroughAlpha(for renderMode: SceneChannelRenderMode) -> Float { + if ghostsInPassthrough, case let .passthroughGhost(opacity) = renderMode { + return opacity + } + return 1.0 + } } - private static func opaqueLODDraws(entityId: EntityID, renderComponent: RenderComponent) -> [LODDitherDraw] { - guard LODConfig.shared.enableFadeTransitions, - let lod = scene.get(component: LODComponent.self, for: entityId), + /// The outgoing and the incoming level of an entity whose LOD fade is running, or nil + /// when the entity just draws its meshes. `lod` is `OpaquePassState.fadingLOD(of:)`. + private static func lodFadeDraws(lod: LODComponent?, renderComponent: RenderComponent) -> [LODDitherDraw]? { + guard let lod, let previousLOD = lod.previousLOD, previousLOD >= 0, previousLOD < lod.lodLevels.count else { - return [LODDitherDraw(meshes: renderComponent.mesh, threshold: 1.0, mode: 0.0)] + return nil } let previousMeshes = lod.lodLevels[previousLOD].mesh guard !previousMeshes.isEmpty else { - return [LODDitherDraw(meshes: renderComponent.mesh, threshold: 1.0, mode: 0.0)] + return nil } let threshold = simd_clamp(lod.transitionProgress, 0.0, 1.0) @@ -330,10 +344,10 @@ public enum RenderPasses { @inline(__always) private static func applyTileRepresentationDither( - entityId: EntityID, + fade: TileRepresentationFadeComponent?, materialParameters: inout MaterialParametersUniform ) { - guard let fade = scene.get(component: TileRepresentationFadeComponent.self, for: entityId) else { return } + guard let fade else { return } materialParameters.lodDither = simd_float4( simd_clamp(fade.progress, 0.0, 1.0), fade.mode, @@ -347,28 +361,24 @@ public enum RenderPasses { /// An entity with an occluder shell or a running fade draws through the per-entity path even /// while its batch group still contains it (the group is rebuilt without it a few frames /// later), like an entity in a LOD or tile fade. - @inline(__always) - private static func isEntityInMeshOccluderOrFade(_ entityId: EntityID) -> Bool { - scene.get(component: MeshOccluderComponent.self, for: entityId) != nil - || scene.get(component: MeshFadeComponent.self, for: entityId) != nil - } + private static let meshOccluderOrFade: RenderEntityTraits = [.meshOccluder, .meshFade] /// A mesh whose occluder shell has taken over draws no colour: depth comes from the shell /// (`meshOccluderShellExecution`); shadows, physics and picking stay on because /// `RenderComponent.isVisible` is untouched. @inline(__always) - private static func meshSkipsColor(_ entityId: EntityID) -> Bool { - scene.get(component: MeshOccluderComponent.self, for: entityId)?.drawsColor == false + private static func meshSkipsColor(_ entity: RenderSceneSnapshot.Entity, in sceneSnapshot: RenderSceneSnapshot) -> Bool { + sceneSnapshot.meshOccluder(of: entity)?.drawsColor == false } /// The app-driven cross-fade dither: outgoing (mode 2) or incoming (mode 1). Applied last so /// it wins over a LOD or tile fade on the same entity. @inline(__always) private static func applyMeshFadeDither( - entityId: EntityID, + fade: MeshFadeComponent?, materialParameters: inout MaterialParametersUniform ) { - guard let fade = scene.get(component: MeshFadeComponent.self, for: entityId) else { return } + guard let fade else { return } let threshold = simd_clamp(fade.progress, 0.0, 1.0) let mode: Float = fade.direction == .fadeOut ? 2.0 : 1.0 materialParameters.lodDither = simd_float4(threshold, mode, 0.0, 0.0) @@ -485,14 +495,10 @@ public enum RenderPasses { } } - @inline(__always) - private static func shouldSkipShadowEntity(_ entityId: EntityID) -> Bool { - if scene.mask(for: entityId) == nil { return true } - if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { return true } - if scene.get(component: CameraComponent.self, for: entityId) != nil { return true } - if scene.get(component: LightComponent.self, for: entityId) != nil { return true } - if scene.get(component: GizmoComponent.self, for: entityId) != nil { return true } - return false + /// A shadow caster chosen for a light, with the components its draws read. + private struct ShadowCaster { + let entity: RenderSceneSnapshot.Entity + let components: RenderSceneSnapshot.DrawComponents } private static func shadowFrustum(for cascadeIdx: Int) -> Frustum? { @@ -554,31 +560,28 @@ public enum RenderPasses { /// /// Only filters by stable properties (component presence). Per-frame culls — isVisible, /// scene-channel visibility, distance, cascade frustum — are applied per-cascade in - /// shadowCasterEntityIds so this scan runs at most once per dirty event, not every frame. + /// shadowCasters(for:in:) so this scan runs at most once per dirty event, not every frame. private static func rebuildShadowEntityCache() { - let transformId = getComponentId(for: WorldTransformComponent.self) - let localTransformId = getComponentId(for: LocalTransformComponent.self) - let renderId = getComponentId(for: RenderComponent.self) - let entities = queryEntitiesWithComponentIds([transformId, localTransformId, renderId], in: scene) + let sceneSnapshot = RenderSceneSnapshot() var candidates: [EntityID] = [] - candidates.reserveCapacity(entities.count / 4) + candidates.reserveCapacity(sceneSnapshot.entityCapacity / 4) let batchingEnabled = BatchingSystem.shared.isEnabled() - for entityId in entities { - if shouldSkipShadowEntity(entityId) { continue } - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), + sceneSnapshot.forEachEntity(with: .drawable) { entity in + if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .light, .gizmo]) { return } + guard let renderComponent = sceneSnapshot.render(of: entity), renderComponent.castsShadow - else { continue } + else { return } // Batch-eligible entities always cast shadows via shadowCasterBatchGroups. // Excluding them here prevents O(n_loaded_tiles) individual shadow draw calls. // A mesh carrying an occluder shell or fade is out of its batch and casts on its // own (the system that adds or removes those components invalidates this cache). if batchingEnabled, - scene.get(component: StaticBatchComponent.self, for: entityId) != nil, - !isEntityInMeshOccluderOrFade(entityId) - { continue } - candidates.append(entityId) + entity.traits.contains(.staticBatch), + entity.traits.isDisjoint(with: meshOccluderOrFade) + { return } + candidates.append(entity.entityId) } runtimeState.lock.lock() @@ -612,7 +615,7 @@ public enum RenderPasses { return runtimeState.shadowEntityCandidates } - private static func shadowCasterEntityIds(for cascadeIdx: Int) -> [EntityID] { + private static func shadowCasters(for cascadeIdx: Int, in sceneSnapshot: RenderSceneSnapshot) -> [ShadowCaster] { ensureShadowCacheConfigured() guard let frustum = shadowFrustum(for: cascadeIdx) else { return [] } @@ -638,17 +641,20 @@ public enum RenderPasses { let candidates = runtimeState.shadowEntityCandidates runtimeState.lock.unlock() - var result: [EntityID] = [] + var result: [ShadowCaster] = [] result.reserveCapacity(candidates.count / 4) // An object too small to be drawn casts a shadow about as small. let smallObjectCulling = SmallObjectCulling.forCurrentFrame() + let batchingEnabled = BatchingSystem.shared.isEnabled() + let sceneRootMatrix = SceneRootTransform.shared.matrix + var renderModes = SceneChannelRenderModeMemo() for entityId in candidates { - guard scene.mask(for: entityId) != nil else { continue } - if shouldHideSceneEntity(entityId: entityId) { continue } - if shouldRenderSceneEntityAsWireframe(entityId: entityId) { continue } - if BatchingSystem.shared.isEnabled() { + guard let entity = sceneSnapshot.entity(entityId) else { continue } + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { continue } + if batchingEnabled { // Batch-eligible entities (StaticBatchComponent present) are always drawn // via shadowCasterBatchGroups — whether or not the batch rebuild has landed // yet. Skipping them here prevents O(n_loaded_tiles) shadow draw calls that @@ -657,17 +663,17 @@ public enum RenderPasses { // preferable to the alternative of the app freezing at ~300+ loaded tiles. // The exception is a mesh carrying an occluder shell or fade: it stays out of // its batch for as long as the component is there, so it casts on its own. - if scene.get(component: StaticBatchComponent.self, for: entityId) != nil, - !isEntityInMeshOccluderOrFade(entityId) + if entity.traits.contains(.staticBatch), + entity.traits.isDisjoint(with: meshOccluderOrFade) { continue } if BatchingSystem.shared.isBatched(entityId: entityId) { continue } } - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), - renderComponent.isVisible, - let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId), - let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) + guard let components = sceneSnapshot.drawComponents(of: entity), + components.render.isVisible else { continue } + let worldTransformComponent = components.world + let localTransformComponent = components.local let (worldMin, worldMax) = worldAABB_MinMax( localMin: localTransformComponent.boundingBox.min, @@ -690,7 +696,7 @@ public enum RenderPasses { let (visualWorldMin, visualWorldMax) = worldAABB_MinMax( localMin: localTransformComponent.boundingBox.min, localMax: localTransformComponent.boundingBox.max, - worldMatrix: simd_mul(SceneRootTransform.shared.matrix, worldTransformComponent.space) + worldMatrix: simd_mul(sceneRootMatrix, worldTransformComponent.space) ) if shadowEntityBeyondMaxDistance( worldMin: visualWorldMin, worldMax: visualWorldMax, @@ -698,7 +704,7 @@ public enum RenderPasses { maxDistance: cascadeReach ) { continue } if isAABBInFrustum(frustum, min: worldMin, max: worldMax) { - result.append(entityId) + result.append(ShadowCaster(entity: entity, components: components)) } } @@ -715,7 +721,7 @@ public enum RenderPasses { } } - private static func spotShadowCasterEntityIds() -> [EntityID] { + private static func spotShadowCasters(in sceneSnapshot: RenderSceneSnapshot) -> [ShadowCaster] { ensureShadowCacheConfigured() guard spotShadowState.isActive, let shadowLight = spotShadowState.light, @@ -736,28 +742,29 @@ public enum RenderPasses { let lightPosition = shadowLight.light.position let maxDistance = max(shadowLight.light.attenuation.w, minimumSpotShadowDistance) let smallObjectCulling = SmallObjectCulling.forCurrentFrame() - var result: [EntityID] = [] + var result: [ShadowCaster] = [] result.reserveCapacity(candidates.count / 4) + let batchingEnabled = BatchingSystem.shared.isEnabled() + var renderModes = SceneChannelRenderModeMemo() + for entityId in candidates { - guard scene.mask(for: entityId) != nil else { continue } - if shouldHideSceneEntity(entityId: entityId) { continue } - if shouldRenderSceneEntityAsWireframe(entityId: entityId) { continue } - if BatchingSystem.shared.isEnabled() { - if scene.get(component: StaticBatchComponent.self, for: entityId) != nil { continue } + guard let entity = sceneSnapshot.entity(entityId) else { continue } + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { continue } + if batchingEnabled { + if entity.traits.contains(.staticBatch) { continue } if BatchingSystem.shared.isBatched(entityId: entityId) { continue } } - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), - renderComponent.isVisible, - let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId), - let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) + guard let components = sceneSnapshot.drawComponents(of: entity), + components.render.isVisible else { continue } let (worldMin, worldMax) = worldAABB_MinMax( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space + localMin: components.local.boundingBox.min, + localMax: components.local.boundingBox.max, + worldMatrix: components.world.space ) if let smallObjectCulling, smallObjectCulling.culls(worldMin: worldMin, worldMax: worldMax) { continue } if shadowEntityBeyondMaxDistance( @@ -767,14 +774,14 @@ public enum RenderPasses { maxDistance: maxDistance ) { continue } if isAABBInFrustum(frustum, min: worldMin, max: worldMax) { - result.append(entityId) + result.append(ShadowCaster(entity: entity, components: components)) } } return result } - private static func pointShadowCasterEntityIds() -> [EntityID] { + private static func pointShadowCasters(in sceneSnapshot: RenderSceneSnapshot) -> [ShadowCaster] { ensureShadowCacheConfigured() guard pointShadowState.isActive, let shadowLight = pointShadowState.light @@ -794,29 +801,30 @@ public enum RenderPasses { let lightPosition = shadowLight.light.position let maxDistance = max(shadowLight.light.radius, minimumPointShadowDistance) let smallObjectCulling = SmallObjectCulling.forCurrentFrame() - var result: [EntityID] = [] + var result: [ShadowCaster] = [] result.reserveCapacity(candidates.count / 4) + let batchingEnabled = BatchingSystem.shared.isEnabled() + var renderModes = SceneChannelRenderModeMemo() + for entityId in candidates { if entityId == shadowLight.entityId { continue } - guard scene.mask(for: entityId) != nil else { continue } - if shouldHideSceneEntity(entityId: entityId) { continue } - if shouldRenderSceneEntityAsWireframe(entityId: entityId) { continue } - if BatchingSystem.shared.isEnabled() { - if scene.get(component: StaticBatchComponent.self, for: entityId) != nil { continue } + guard let entity = sceneSnapshot.entity(entityId) else { continue } + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { continue } + if batchingEnabled { + if entity.traits.contains(.staticBatch) { continue } if BatchingSystem.shared.isBatched(entityId: entityId) { continue } } - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), - renderComponent.isVisible, - let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId), - let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) + guard let components = sceneSnapshot.drawComponents(of: entity), + components.render.isVisible else { continue } let (worldMin, worldMax) = worldAABB_MinMax( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space + localMin: components.local.boundingBox.min, + localMax: components.local.boundingBox.max, + worldMatrix: components.world.space ) if let smallObjectCulling, smallObjectCulling.culls(worldMin: worldMin, worldMax: worldMax) { continue } if shadowEntityBeyondMaxDistance( @@ -826,7 +834,7 @@ public enum RenderPasses { maxDistance: maxDistance ) { continue } - result.append(entityId) + result.append(ShadowCaster(entity: entity, components: components)) } return result @@ -1310,6 +1318,10 @@ public enum RenderPasses { } let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) + // What every draw of the pass shares, read once. + let sceneSnapshot = RenderSceneSnapshot() + let cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let projectionMatrix = renderInfo.perspectiveSpace // Render each cascade into its own depth array slice. for cascadeIdx in 0 ..< csmCascadeCount { @@ -1344,13 +1356,12 @@ public enum RenderPasses { index: Int(shadowPassLightMatrixUniform.rawValue) ) - let shadowCasterIds = shadowCasterEntityIds(for: cascadeIdx) - for entityId in shadowCasterIds { - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { continue } - guard let transformComponent = scene.get(component: WorldTransformComponent.self, for: entityId) else { continue } - guard scene.get(component: LocalTransformComponent.self, for: entityId) != nil else { continue } + for caster in shadowCasters(for: cascadeIdx, in: sceneSnapshot) { + let transformComponent = caster.components.world + let deformation = sceneSnapshot.deformation(of: caster.entity) + let hasSkeleton = caster.entity.traits.contains(.skeleton) - for mesh in renderComponent.mesh { + for mesh in caster.components.render.mesh { var modelUniforms = Uniforms() var modelMatrix = simd_mul(transformComponent.space, mesh.localSpace) let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) @@ -1361,14 +1372,14 @@ public enum RenderPasses { modelUniforms.normalMatrix = normalMatrix modelUniforms.viewMatrix = viewMatrix modelUniforms.modelMatrix = modelMatrix - modelUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) - modelUniforms.projectionMatrix = renderInfo.perspectiveSpace + modelUniforms.cameraPosition = cameraPosition + modelUniforms.projectionMatrix = projectionMatrix renderEncoder.setVertexBytes( &modelUniforms, length: MemoryLayout.stride, index: Int(shadowPassModelUniform.rawValue) ) - renderEncoder.bindShadowVertexStreams(mesh: mesh, entityId: entityId) + renderEncoder.bindShadowVertexStreams(mesh: mesh, deformation: deformation, hasSkeleton: hasSkeleton) // Glass casts no shadow: the light crosses it. for subMesh in mesh.submeshes where subMesh.material?.transmitsLight != true { @@ -1545,24 +1556,27 @@ public enum RenderPasses { ) let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - for entityId in spotShadowCasterEntityIds() { - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), - let transformComponent = scene.get(component: WorldTransformComponent.self, for: entityId), - scene.get(component: LocalTransformComponent.self, for: entityId) != nil - else { continue } - - for mesh in renderComponent.mesh { + // What every draw of the pass shares, read once. + let sceneSnapshot = RenderSceneSnapshot() + let cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let projectionMatrix = renderInfo.perspectiveSpace + for caster in spotShadowCasters(in: sceneSnapshot) { + let transformComponent = caster.components.world + let deformation = sceneSnapshot.deformation(of: caster.entity) + let hasSkeleton = caster.entity.traits.contains(.skeleton) + + for mesh in caster.components.render.mesh { var modelUniforms = Uniforms() let modelMatrix = simd_mul(transformComponent.space, mesh.localSpace) modelUniforms.modelViewMatrix = simd_mul(viewMatrix, modelMatrix) modelUniforms.normalMatrix = matrix3x3_upper_left(modelMatrix).inverse.transpose modelUniforms.viewMatrix = viewMatrix modelUniforms.modelMatrix = modelMatrix - modelUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) - modelUniforms.projectionMatrix = renderInfo.perspectiveSpace + modelUniforms.cameraPosition = cameraPosition + modelUniforms.projectionMatrix = projectionMatrix renderEncoder.setVertexBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(shadowPassModelUniform.rawValue)) - renderEncoder.bindShadowVertexStreams(mesh: mesh, entityId: entityId) + renderEncoder.bindShadowVertexStreams(mesh: mesh, deformation: deformation, hasSkeleton: hasSkeleton) // Glass casts no shadow: the light crosses it. for subMesh in mesh.submeshes where subMesh.material?.transmitsLight != true { @@ -1642,7 +1656,11 @@ public enum RenderPasses { } let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - let casterEntityIds = pointShadowCasterEntityIds() + // What every draw of the pass shares, read once. + let sceneSnapshot = RenderSceneSnapshot() + let cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let projectionMatrix = renderInfo.perspectiveSpace + let casters = pointShadowCasters(in: sceneSnapshot) let batchGroups = BatchingSystem.shared.isEnabled() ? pointShadowCasterBatchGroups() : [] for face in 0 ..< min(renderInfo.pointShadowRenderPassDescriptors.count, pointShadowState.lightSpaceMatrices.count) { @@ -1676,24 +1694,23 @@ public enum RenderPasses { index: Int(shadowPassLightMatrixUniform.rawValue) ) - for entityId in casterEntityIds { - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), - let transformComponent = scene.get(component: WorldTransformComponent.self, for: entityId), - scene.get(component: LocalTransformComponent.self, for: entityId) != nil - else { continue } + for caster in casters { + let transformComponent = caster.components.world + let deformation = sceneSnapshot.deformation(of: caster.entity) + let hasSkeleton = caster.entity.traits.contains(.skeleton) - for mesh in renderComponent.mesh { + for mesh in caster.components.render.mesh { var modelUniforms = Uniforms() let modelMatrix = simd_mul(transformComponent.space, mesh.localSpace) modelUniforms.modelViewMatrix = simd_mul(viewMatrix, modelMatrix) modelUniforms.normalMatrix = matrix3x3_upper_left(modelMatrix).inverse.transpose modelUniforms.viewMatrix = viewMatrix modelUniforms.modelMatrix = modelMatrix - modelUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) - modelUniforms.projectionMatrix = renderInfo.perspectiveSpace + modelUniforms.cameraPosition = cameraPosition + modelUniforms.projectionMatrix = projectionMatrix renderEncoder.setVertexBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(shadowPassModelUniform.rawValue)) - renderEncoder.bindShadowVertexStreams(mesh: mesh, entityId: entityId) + renderEncoder.bindShadowVertexStreams(mesh: mesh, deformation: deformation, hasSkeleton: hasSkeleton) // Glass casts no shadow: the light crosses it. for subMesh in mesh.submeshes where subMesh.material?.transmitsLight != true { @@ -1817,53 +1834,62 @@ public enum RenderPasses { renderEncoder.waitForFence(renderInfo.fence, before: .vertex) - // Create a component query for entities with both Transform and Render components + // What every draw of the pass shares, read once. + let pass = OpaquePassState(cameraComponent: cameraComponent) + let sceneSnapshot = pass.sceneSnapshot + let viewMatrix = pass.viewMatrix + var renderModes = SceneChannelRenderModeMemo() - // Iterate over the entities found by the component query + // Iterate over the visible entities for entityId in visibleEntityIds { - // Skip entities that are pending destroy - if scene.mask(for: entityId) == nil { continue } - if shouldHideSceneEntity(entityId: entityId) { continue } - if shouldRenderSceneEntityAsWireframe(entityId: entityId) { continue } + // Skip entities that are gone or pending destroy + guard let entity = sceneSnapshot.entity(entityId) else { continue } + let traits = entity.traits + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { continue } // Skip batched entities if batching is enabled - if BatchingSystem.shared.isEnabled(), + let fadingLOD = pass.fadingLOD(of: entity) + if pass.batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId), - !isEntityInActiveLODFade(entityId), - !isEntityInActiveTileRepresentationFade(entityId), - !isEntityInMeshOccluderOrFade(entityId) + fadingLOD?.previousLOD == nil, + !traits.contains(.tileRepresentationFade), + traits.isDisjoint(with: meshOccluderOrFade) { continue } - if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { continue } - if scene.get(component: CameraComponent.self, for: entityId) != nil { continue } - if meshSkipsColor(entityId) { continue } + if !traits.isDisjoint(with: [.sceneCamera, .camera]) { continue } + if meshSkipsColor(entity, in: sceneSnapshot) { continue } - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { - handleError(.noRenderComponent, entityId) - continue - } - - guard let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId) else { - handleError(.noWorldTransformComponent, entityId) - continue - } - - guard let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) else { - handleError(.noLocalTransformComponent, entityId) - continue - } - - if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { + guard let components = sceneSnapshot.drawComponents(of: entity) else { + if !traits.contains(.render) { + handleError(.noRenderComponent, entityId) + } else if !traits.contains(.worldTransform) { + handleError(.noWorldTransformComponent, entityId) + } else { + handleError(.noLocalTransformComponent, entityId) + } continue } + let renderComponent = components.render + let worldTransformComponent = components.world - if hasComponent(entityId: entityId, componentType: LightComponent.self) { + if !traits.isDisjoint(with: [.gizmo, .light]) { continue } - for lodDraw in opaqueLODDraws(entityId: entityId, renderComponent: renderComponent) { + // What every draw of the entity shares. + let deformation = sceneSnapshot.deformation(of: entity) + let hasSkeleton = traits.contains(.skeleton) + let passthroughAlpha = pass.passthroughAlpha(for: renderMode) + let tileRepresentationFade = sceneSnapshot.tileRepresentationFade(of: entity) + let meshFade = sceneSnapshot.meshFade(of: entity) + + // The entity's meshes, or its outgoing and incoming levels while a LOD fade runs. + let fadeDraws = lodFadeDraws(lod: fadingLOD, renderComponent: renderComponent) + for drawIndex in 0 ..< (fadeDraws?.count ?? 1) { + let lodDraw = fadeDraws?[drawIndex] ?? LODDitherDraw(meshes: renderComponent.mesh, threshold: 1.0, mode: 0.0) for mesh in lodDraw.meshes { // update uniforms var modelUniforms = Uniforms() @@ -1871,8 +1897,6 @@ public enum RenderPasses { let rootMatrix = worldTransformComponent.space var modelMatrix = simd_mul(rootMatrix, mesh.localSpace) - let viewMatrix: simd_float4x4 = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) let upperModelMatrix: matrix_float3x3 = matrix3x3_upper_left(modelMatrix) @@ -1889,15 +1913,15 @@ public enum RenderPasses { modelUniforms.modelMatrix = modelMatrix - modelUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + modelUniforms.cameraPosition = pass.cameraPosition - modelUniforms.projectionMatrix = renderInfo.perspectiveSpace + modelUniforms.projectionMatrix = pass.projectionMatrix renderEncoder.setVertexBytes( &modelUniforms, length: MemoryLayout.stride, index: Int(modelPassUniformIndex.rawValue) ) - renderEncoder.bindModelVertexStreams(mesh: mesh, entityId: entityId) + renderEncoder.bindModelVertexStreams(mesh: mesh, deformation: deformation, hasSkeleton: hasSkeleton) renderEncoder.setFragmentBytes( &modelUniforms, length: MemoryLayout.stride, index: Int(modelPassFragmentUniformIndex.rawValue) @@ -1915,7 +1939,7 @@ public enum RenderPasses { renderEncoder.setFragmentBytes(&stScale, length: MemoryLayout.stride, index: Int(modelPassFragmentSTScaleIndex.rawValue)) - var pomQuality = Self.currentPOMQualityUniform() + var pomQuality = pass.pomQuality renderEncoder.setFragmentBytes(&pomQuality, length: MemoryLayout.stride, index: Int(modelPassFragmentPOMQualityIndex.rawValue)) // set base texture @@ -1964,17 +1988,21 @@ public enum RenderPasses { materialParameters.ior = material.ior materialParameters.edgeTint = material.edgeTint materialParameters.alphaCutoff = material.alphaCutoff - materialParameters.passthroughAlpha = passthroughGhostAlpha(for: entityId) + materialParameters.passthroughAlpha = passthroughAlpha materialParameters.alphaMode = Int32(material.alphaMode.rawValue) materialParameters.interactWithLight = material.interactWithLight materialParameters.emmissive = material.emissiveValue applyMaterialTextureState(material: material, materialParameters: &materialParameters) - applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) - applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + if pass.colorsByLOD { + applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + } + if pass.colorsByStreamingTier { + applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + } applyLODDither(draw: lodDraw, materialParameters: &materialParameters) - applyTileRepresentationDither(entityId: entityId, materialParameters: &materialParameters) - applyMeshFadeDither(entityId: entityId, materialParameters: &materialParameters) + applyTileRepresentationDither(fade: tileRepresentationFade, materialParameters: &materialParameters) + applyMeshFadeDither(fade: meshFade, materialParameters: &materialParameters) renderEncoder.setFragmentBytes( &materialParameters, length: MemoryLayout.stride, @@ -2306,7 +2334,11 @@ public enum RenderPasses { renderEncoder.label = "G-Buffer + Light Pass (TBDR)" renderEncoder.pushDebugGroup("G-Buffer + Light Pass (TBDR)") - let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) + // What every draw of the pass shares, read once. + let pass = OpaquePassState(cameraComponent: cameraComponent) + let sceneSnapshot = pass.sceneSnapshot + let viewMatrix = pass.viewMatrix + var renderModes = SceneChannelRenderModeMemo() // ── Sub-pass 1: Unbatched geometry ────────────────────────────────────── renderEncoder.setRenderPipelineState(modelPipeline.pipelineState!) @@ -2314,26 +2346,35 @@ public enum RenderPasses { renderEncoder.waitForFence(renderInfo.fence, before: .vertex) for entityId in visibleEntityIds { - if scene.mask(for: entityId) == nil { continue } - if shouldHideSceneEntity(entityId: entityId) { continue } - if shouldRenderSceneEntityAsWireframe(entityId: entityId) { continue } - if BatchingSystem.shared.isEnabled(), + guard let entity = sceneSnapshot.entity(entityId) else { continue } + let traits = entity.traits + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { continue } + let fadingLOD = pass.fadingLOD(of: entity) + if pass.batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId), - !isEntityInActiveLODFade(entityId), - !isEntityInActiveTileRepresentationFade(entityId), - !isEntityInMeshOccluderOrFade(entityId) + fadingLOD?.previousLOD == nil, + !traits.contains(.tileRepresentationFade), + traits.isDisjoint(with: meshOccluderOrFade) { continue } - if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { continue } - if scene.get(component: CameraComponent.self, for: entityId) != nil { continue } - if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { continue } - if hasComponent(entityId: entityId, componentType: LightComponent.self) { continue } - if meshSkipsColor(entityId) { continue } - - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { continue } - guard let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId) else { continue } - guard scene.get(component: LocalTransformComponent.self, for: entityId) != nil else { continue } - - for lodDraw in opaqueLODDraws(entityId: entityId, renderComponent: renderComponent) { + if !traits.isDisjoint(with: [.sceneCamera, .camera, .gizmo, .light]) { continue } + if meshSkipsColor(entity, in: sceneSnapshot) { continue } + + guard let components = sceneSnapshot.drawComponents(of: entity) else { continue } + let renderComponent = components.render + let worldTransformComponent = components.world + + // What every draw of the entity shares. + let deformation = sceneSnapshot.deformation(of: entity) + let hasSkeleton = traits.contains(.skeleton) + let passthroughAlpha = pass.passthroughAlpha(for: renderMode) + let tileRepresentationFade = sceneSnapshot.tileRepresentationFade(of: entity) + let meshFade = sceneSnapshot.meshFade(of: entity) + + // The entity's meshes, or its outgoing and incoming levels while a LOD fade runs. + let fadeDraws = lodFadeDraws(lod: fadingLOD, renderComponent: renderComponent) + for drawIndex in 0 ..< (fadeDraws?.count ?? 1) { + let lodDraw = fadeDraws?[drawIndex] ?? LODDitherDraw(meshes: renderComponent.mesh, threshold: 1.0, mode: 0.0) for mesh in lodDraw.meshes { var modelUniforms = Uniforms() let modelMatrix = simd_mul(worldTransformComponent.space, mesh.localSpace) @@ -2344,12 +2385,12 @@ public enum RenderPasses { modelUniforms.normalMatrix = normalMatrix modelUniforms.viewMatrix = viewMatrix modelUniforms.modelMatrix = modelMatrix - modelUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) - modelUniforms.projectionMatrix = renderInfo.perspectiveSpace + modelUniforms.cameraPosition = pass.cameraPosition + modelUniforms.projectionMatrix = pass.projectionMatrix renderEncoder.setVertexBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassUniformIndex.rawValue)) - renderEncoder.bindModelVertexStreams(mesh: mesh, entityId: entityId) + renderEncoder.bindModelVertexStreams(mesh: mesh, deformation: deformation, hasSkeleton: hasSkeleton) renderEncoder.setFragmentBytes(&modelUniforms, length: MemoryLayout.stride, index: Int(modelPassFragmentUniformIndex.rawValue)) @@ -2359,7 +2400,7 @@ public enum RenderPasses { var stScale: Float = material.stScale renderEncoder.setFragmentBytes(&stScale, length: MemoryLayout.stride, index: Int(modelPassFragmentSTScaleIndex.rawValue)) - var pomQuality = Self.currentPOMQualityUniform() + var pomQuality = pass.pomQuality renderEncoder.setFragmentBytes(&pomQuality, length: MemoryLayout.stride, index: Int(modelPassFragmentPOMQualityIndex.rawValue)) renderEncoder.setFragmentTexture(material.baseColor.texture, index: Int(modelPassBaseTextureIndex.rawValue)) renderEncoder.setFragmentSamplerState(material.baseColor.sampler, index: Int(modelPassBaseSamplerIndex.rawValue)) @@ -2387,16 +2428,20 @@ public enum RenderPasses { materialParameters.ior = material.ior materialParameters.edgeTint = material.edgeTint materialParameters.alphaCutoff = material.alphaCutoff - materialParameters.passthroughAlpha = passthroughGhostAlpha(for: entityId) + materialParameters.passthroughAlpha = passthroughAlpha materialParameters.alphaMode = Int32(material.alphaMode.rawValue) materialParameters.interactWithLight = material.interactWithLight materialParameters.emmissive = material.emissiveValue applyMaterialTextureState(material: material, materialParameters: &materialParameters) - applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) - applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + if pass.colorsByLOD { + applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + } + if pass.colorsByStreamingTier { + applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + } applyLODDither(draw: lodDraw, materialParameters: &materialParameters) - applyTileRepresentationDither(entityId: entityId, materialParameters: &materialParameters) - applyMeshFadeDither(entityId: entityId, materialParameters: &materialParameters) + applyTileRepresentationDither(fade: tileRepresentationFade, materialParameters: &materialParameters) + applyMeshFadeDither(fade: meshFade, materialParameters: &materialParameters) renderEncoder.setFragmentBytes(&materialParameters, length: MemoryLayout.stride, index: Int(modelPassFragmentMaterialParameterIndex.rawValue)) renderEncoder.setFragmentTexture(material.normal.texture, index: Int(modelPassNormalTextureIndex.rawValue)) @@ -3682,29 +3727,42 @@ public enum RenderPasses { let effectiveCameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + // What every draw of the pass shares, read once. + let sceneSnapshot = RenderSceneSnapshot() + let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) + let projectionMatrix = renderInfo.perspectiveSpace + let batchingEnabled = BatchingSystem.shared.isEnabled() + let colorsByLOD = SpatialDebugVisualization.shared.colorRenderablesByLOD + let colorsByStreamingTier = SpatialDebugVisualization.shared.colorRenderablesByStreamingTier + var renderModes = SceneChannelRenderModeMemo() + // Build and sort transparent draw items back-to-front for correct alpha blending. - var transparentEntities: [(entityId: EntityID, render: RenderComponent, world: WorldTransformComponent, distanceSq: Float)] = [] + var transparentEntities: [( + entityId: EntityID, + render: RenderComponent, + world: WorldTransformComponent, + deformation: DeformationComponent?, + hasSkeleton: Bool, + distanceSq: Float + )] = [] for entityId in visibleEntityIds { - if scene.mask(for: entityId) == nil { continue } - if shouldHideSceneEntity(entityId: entityId) { continue } - if shouldRenderSceneEntityAsWireframe(entityId: entityId) { continue } + guard let entity = sceneSnapshot.entity(entityId) else { continue } + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { continue } - if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { + if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId) { continue } - if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { continue } - if scene.get(component: CameraComponent.self, for: entityId) != nil { continue } - if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { continue } - if hasComponent(entityId: entityId, componentType: LightComponent.self) { continue } + if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .gizmo, .light]) { continue } // Blend submeshes go with the colour (there is no dither path here). - if meshSkipsColor(entityId) { continue } + if meshSkipsColor(entity, in: sceneSnapshot) { continue } - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { + guard let renderComponent = sceneSnapshot.render(of: entity) else { continue } - guard let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId) else { + guard let worldTransformComponent = sceneSnapshot.worldTransform(of: entity) else { continue } @@ -3728,6 +3786,8 @@ public enum RenderPasses { entityId: entityId, render: renderComponent, world: worldTransformComponent, + deformation: sceneSnapshot.deformation(of: entity), + hasSkeleton: entity.traits.contains(.skeleton), distanceSq: distanceSq )) } @@ -3743,7 +3803,6 @@ public enum RenderPasses { var modelUniforms = Uniforms() let rootMatrix = worldTransformComponent.space let modelMatrix = simd_mul(rootMatrix, mesh.localSpace) - let viewMatrix: simd_float4x4 = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) let modelViewMatrix = simd_mul(viewMatrix, modelMatrix) let upperModelMatrix: matrix_float3x3 = matrix3x3_upper_left(modelMatrix) let inverseUpperModelMatrix: matrix_float3x3 = upperModelMatrix.inverse @@ -3754,7 +3813,7 @@ public enum RenderPasses { modelUniforms.viewMatrix = viewMatrix modelUniforms.modelMatrix = modelMatrix modelUniforms.cameraPosition = effectiveCameraPosition - modelUniforms.projectionMatrix = renderInfo.perspectiveSpace + modelUniforms.projectionMatrix = projectionMatrix renderEncoder.setVertexBytes( &modelUniforms, @@ -3762,7 +3821,7 @@ public enum RenderPasses { index: Int(modelPassUniformIndex.rawValue) ) - renderEncoder.bindModelVertexStreams(mesh: mesh, entityId: entityId) + renderEncoder.bindModelVertexStreams(mesh: mesh, deformation: entry.deformation, hasSkeleton: entry.hasSkeleton) renderEncoder.setFragmentBytes( &modelUniforms, @@ -3837,8 +3896,12 @@ public enum RenderPasses { materialParameters.interactWithLight = material.interactWithLight materialParameters.emmissive = material.emissiveValue applyMaterialTextureState(material: material, materialParameters: &materialParameters) - applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) - applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + if colorsByLOD { + applyLODDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + } + if colorsByStreamingTier { + applyStreamingTierDebugColorOverride(entityId: entityId, materialParameters: &materialParameters) + } renderEncoder.setFragmentBytes( &materialParameters, @@ -3914,17 +3977,19 @@ public enum RenderPasses { return } + let sceneSnapshot = RenderSceneSnapshot() let shellEntityIds = visibleEntityIds.filter { entityId in - guard scene.mask(for: entityId) != nil, - scene.get(component: MeshOccluderComponent.self, for: entityId) != nil + guard let entity = sceneSnapshot.entity(entityId), + entity.traits.contains(.meshOccluder) else { return false } - if shouldHideSceneEntity(entityId: entityId) || shouldRenderSceneEntityAsWireframe(entityId: entityId) { + let renderMode = getSceneChannelRenderMode(sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { return false } - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), + guard let renderComponent = sceneSnapshot.render(of: entity), renderComponent.isVisible, !renderComponent.mesh.isEmpty, - scene.get(component: WorldTransformComponent.self, for: entityId) != nil + entity.traits.contains(.worldTransform) else { return false } return true } @@ -4029,36 +4094,33 @@ public enum RenderPasses { let wireframeSettings = wireframeRenderState wireframeRenderStateLock.unlock() - let renderId = getComponentId(for: RenderComponent.self) - let transformId = getComponentId(for: WorldTransformComponent.self) - let localTransformId = getComponentId(for: LocalTransformComponent.self) - let wireframeEntityIds = queryEntitiesWithComponentIds([renderId, transformId, localTransformId], in: scene).filter { entityId in - if scene.mask(for: entityId) == nil { return false } - if shouldHideSceneEntity(entityId: entityId) { return false } - if !shouldRenderSceneEntityAsWireframe(entityId: entityId) { return false } - if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { return false } - if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { return false } - if scene.get(component: CameraComponent.self, for: entityId) != nil { return false } - if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { return false } - if hasComponent(entityId: entityId, componentType: LightComponent.self) { return false } - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), - renderComponent.isVisible, - let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId), - let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) - else { return false } + let sceneSnapshot = RenderSceneSnapshot() + let batchingEnabled = BatchingSystem.shared.isEnabled() + let frustum = currentFrameFrustum + var renderModes = SceneChannelRenderModeMemo() + var wireframeEntityIds: [EntityID] = [] + sceneSnapshot.forEachEntity(with: .drawable) { entity in + // A hidden channel wins over a wireframe one, so the mode alone tells both. + guard renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) == .wireframe else { return } + let entityId = entity.entityId + if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId) { return } + if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .gizmo, .light]) { return } + guard let components = sceneSnapshot.drawComponents(of: entity), + components.render.isVisible + else { return } let (worldMin, worldMax) = worldAABB_MinMax( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space + localMin: components.local.boundingBox.min, + localMax: components.local.boundingBox.max, + worldMatrix: components.world.space ) if shouldCullWireframeByDistanceFade( bounds: AABB(min: worldMin, max: worldMax), cameraPosition: effectiveCameraPosition, settings: wireframeSettings - ) { return false } - guard let frustum = currentFrameFrustum else { return true } - return isAABBInFrustum(frustum, min: worldMin, max: worldMax) + ) { return } + if let frustum, !isAABBInFrustum(frustum, min: worldMin, max: worldMax) { return } + wireframeEntityIds.append(entityId) } let wireframeBatchGroups = BatchingSystem.shared.isEnabled() ? BatchingSystem.shared.batchGroups.filter { batchGroup in @@ -4218,24 +4280,22 @@ public enum RenderPasses { let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) let effectiveCameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let sceneSnapshot = RenderSceneSnapshot() + let batchingEnabled = BatchingSystem.shared.isEnabled() + var renderModes = SceneChannelRenderModeMemo() let wireframeEntityIds = visibleEntityIds.filter { entityId in - if scene.mask(for: entityId) == nil { return false } - if shouldHideSceneEntity(entityId: entityId) { return false } - if !shouldRenderSceneEntityAsWireframe(entityId: entityId) { return false } - if BatchingSystem.shared.isEnabled(), BatchingSystem.shared.isBatched(entityId: entityId) { return false } - if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { return false } - if scene.get(component: CameraComponent.self, for: entityId) != nil { return false } - if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { return false } - if hasComponent(entityId: entityId, componentType: LightComponent.self) { return false } - guard scene.get(component: RenderComponent.self, for: entityId) != nil, - let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId), - let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) + // A hidden channel wins over a wireframe one, so the mode alone tells both. + guard let entity = sceneSnapshot.entity(entityId), + renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) == .wireframe else { return false } + if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId) { return false } + if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .gizmo, .light]) { return false } + guard let components = sceneSnapshot.drawComponents(of: entity) else { return false } let (worldMin, worldMax) = worldAABB_MinMax( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, - worldMatrix: worldTransformComponent.space + localMin: components.local.boundingBox.min, + localMax: components.local.boundingBox.max, + worldMatrix: components.world.space ) return !shouldCullWireframeByDistanceFade( bounds: AABB(min: worldMin, max: worldMax), diff --git a/Sources/UntoldEngine/Renderer/RenderSceneSnapshot.swift b/Sources/UntoldEngine/Renderer/RenderSceneSnapshot.swift new file mode 100644 index 000000000..b707dcacf --- /dev/null +++ b/Sources/UntoldEngine/Renderer/RenderSceneSnapshot.swift @@ -0,0 +1,281 @@ +// +// RenderSceneSnapshot.swift +// UntoldEngine +// +// The scene as one render pass reads it: one read of the scene, with the component +// ids and the component storage the passes need looked up once. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation + +/// Which of the components the render passes ask about an entity has. +/// +/// Taken from the entity's component mask, so that a pass reads the mask once and +/// tests bits where it used to ask the scene about each component in turn. +struct RenderEntityTraits: OptionSet { + let rawValue: UInt32 + + // What a pass needs to draw the entity. + static let render = RenderEntityTraits(rawValue: 1 << 0) + static let worldTransform = RenderEntityTraits(rawValue: 1 << 1) + static let localTransform = RenderEntityTraits(rawValue: 1 << 2) + + // Entities no geometry pass draws. + static let gizmo = RenderEntityTraits(rawValue: 1 << 3) + static let light = RenderEntityTraits(rawValue: 1 << 4) + static let camera = RenderEntityTraits(rawValue: 1 << 5) + static let sceneCamera = RenderEntityTraits(rawValue: 1 << 6) + + // How the entity is drawn. + static let sceneChannels = RenderEntityTraits(rawValue: 1 << 7) + static let staticBatch = RenderEntityTraits(rawValue: 1 << 8) + static let meshOccluder = RenderEntityTraits(rawValue: 1 << 9) + static let meshFade = RenderEntityTraits(rawValue: 1 << 10) + static let tileRepresentationFade = RenderEntityTraits(rawValue: 1 << 11) + static let lod = RenderEntityTraits(rawValue: 1 << 12) + static let deformation = RenderEntityTraits(rawValue: 1 << 13) + static let skeleton = RenderEntityTraits(rawValue: 1 << 14) + static let tileLODTag = RenderEntityTraits(rawValue: 1 << 15) + + /// The components every draw of an entity reads. + static let drawable: RenderEntityTraits = [.render, .worldTransform, .localTransform] +} + +/// The scene as one render pass reads it. +/// +/// A pass asks the same few things about thousands of entities: is it alive, which of +/// a dozen components does it have, where are its render and transform components. +/// Asked through `scene`, each of those questions takes the scene lock, copies the +/// scene and looks the component's id up. A snapshot reads the scene once, looks the +/// ids and the component storage up once, and answers from what it took. +/// +/// Build one when a pass starts and let it go when the pass ends. An entity created or +/// destroyed, or a component added or removed, while it is held shows in the next one. +/// The components it hands out are the scene's own objects, so their values are +/// always the current ones. +struct RenderSceneSnapshot { + /// An entity that was alive when the snapshot was taken. It belongs to the snapshot + /// that handed it out: another snapshot may have a different entity at its index. + struct Entity { + let entityId: EntityID + let traits: RenderEntityTraits + fileprivate let index: Int + } + + /// The components every draw of an entity reads. + struct DrawComponents { + let render: RenderComponent + let world: WorldTransformComponent + let local: LocalTransformComponent + } + + private let entities: [EntityDesc] + private let ids: ComponentIds + private let pools: ComponentPools + + init() { + let snapshot = scene + entities = snapshot.entities + ids = ComponentIds() + pools = ComponentPools(ids: ids, scene: snapshot) + } + + /// How many entity indices the scene has handed out; some are free. + var entityCapacity: Int { + entities.count + } + + /// The entity, or nil if it is gone, is waiting to be destroyed, or the id is that of + /// an entity that had its index before. + @inline(__always) + func entity(_ entityId: EntityID) -> Entity? { + let index = Int(getEntityIndex(entityId)) + guard index < entities.count else { return nil } + let description = entities[index] + guard description.entityId == entityId, !description.freed, !description.pendingDestroy else { return nil } + return Entity(entityId: entityId, traits: ids.traits(of: description.mask), index: index) + } + + /// Calls `body` for every entity that has all of `traits`, in the order of their indices. + func forEachEntity(with traits: RenderEntityTraits, _ body: (Entity) -> Void) { + for index in entities.indices { + let description = entities[index] + if description.freed || description.pendingDestroy { continue } + let entityTraits = ids.traits(of: description.mask) + guard entityTraits.isSuperset(of: traits) else { continue } + body(Entity(entityId: description.entityId, traits: entityTraits, index: index)) + } + } + + /// The render and transform components of `entity`, or nil if it lacks one of them. + @inline(__always) + func drawComponents(of entity: Entity) -> DrawComponents? { + guard entity.traits.isSuperset(of: .drawable), + let render = object(pools.render, entity, as: RenderComponent.self), + let world = object(pools.world, entity, as: WorldTransformComponent.self), + let local = object(pools.local, entity, as: LocalTransformComponent.self) + else { return nil } + return DrawComponents(render: render, world: world, local: local) + } + + @inline(__always) + func render(of entity: Entity) -> RenderComponent? { + component(.render, pools.render, of: entity) + } + + @inline(__always) + func worldTransform(of entity: Entity) -> WorldTransformComponent? { + component(.worldTransform, pools.world, of: entity) + } + + @inline(__always) + func localTransform(of entity: Entity) -> LocalTransformComponent? { + component(.localTransform, pools.local, of: entity) + } + + @inline(__always) + func lod(of entity: Entity) -> LODComponent? { + component(.lod, pools.lod, of: entity) + } + + @inline(__always) + func meshOccluder(of entity: Entity) -> MeshOccluderComponent? { + component(.meshOccluder, pools.meshOccluder, of: entity) + } + + @inline(__always) + func meshFade(of entity: Entity) -> MeshFadeComponent? { + component(.meshFade, pools.meshFade, of: entity) + } + + @inline(__always) + func tileRepresentationFade(of entity: Entity) -> TileRepresentationFadeComponent? { + component(.tileRepresentationFade, pools.tileRepresentationFade, of: entity) + } + + @inline(__always) + func deformation(of entity: Entity) -> DeformationComponent? { + component(.deformation, pools.deformation, of: entity) + } + + @inline(__always) + func tileLODTag(of entity: Entity) -> TileLODTagComponent? { + component(.tileLODTag, pools.tileLODTag, of: entity) + } + + /// The scene channels of `entity`: those of its channel component, or the ones an + /// entity without the component gets from its name. + @inline(__always) + func sceneChannels(of entity: Entity) -> SceneChannel { + let channels: EntitySceneChannelsComponent? = component(.sceneChannels, pools.sceneChannels, of: entity) + return channels?.channels ?? getEntitySceneChannels(entityId: entity.entityId) + } + + // MARK: - Storage + + @inline(__always) + private func component(_ trait: RenderEntityTraits, _ pool: ComponentPool?, of entity: Entity) -> T? { + guard entity.traits.contains(trait) else { return nil } + return object(pool, entity, as: T.self) + } + + @inline(__always) + private func object(_ pool: ComponentPool?, _ entity: Entity, as _: T.Type) -> T? { + pool?.component(at: entity.index, as: T.self) + } + + /// The ids of the components the passes ask about, looked up once. + private struct ComponentIds { + let render = getComponentId(for: RenderComponent.self) + let world = getComponentId(for: WorldTransformComponent.self) + let local = getComponentId(for: LocalTransformComponent.self) + let gizmo = getComponentId(for: GizmoComponent.self) + let light = getComponentId(for: LightComponent.self) + let camera = getComponentId(for: CameraComponent.self) + let sceneCamera = getComponentId(for: SceneCameraComponent.self) + let sceneChannels = getComponentId(for: EntitySceneChannelsComponent.self) + let staticBatch = getComponentId(for: StaticBatchComponent.self) + let meshOccluder = getComponentId(for: MeshOccluderComponent.self) + let meshFade = getComponentId(for: MeshFadeComponent.self) + let tileRepresentationFade = getComponentId(for: TileRepresentationFadeComponent.self) + let lod = getComponentId(for: LODComponent.self) + let deformation = getComponentId(for: DeformationComponent.self) + let skeleton = getComponentId(for: SkeletonComponent.self) + let tileLODTag = getComponentId(for: TileLODTagComponent.self) + + @inline(__always) + func traits(of mask: ComponentMask) -> RenderEntityTraits { + var traits: RenderEntityTraits = [] + if mask.test(render) { traits.insert(.render) } + if mask.test(world) { traits.insert(.worldTransform) } + if mask.test(local) { traits.insert(.localTransform) } + if mask.test(gizmo) { traits.insert(.gizmo) } + if mask.test(light) { traits.insert(.light) } + if mask.test(camera) { traits.insert(.camera) } + if mask.test(sceneCamera) { traits.insert(.sceneCamera) } + if mask.test(sceneChannels) { traits.insert(.sceneChannels) } + if mask.test(staticBatch) { traits.insert(.staticBatch) } + if mask.test(meshOccluder) { traits.insert(.meshOccluder) } + if mask.test(meshFade) { traits.insert(.meshFade) } + if mask.test(tileRepresentationFade) { traits.insert(.tileRepresentationFade) } + if mask.test(lod) { traits.insert(.lod) } + if mask.test(deformation) { traits.insert(.deformation) } + if mask.test(skeleton) { traits.insert(.skeleton) } + if mask.test(tileLODTag) { traits.insert(.tileLODTag) } + return traits + } + } + + /// The storage of the components the passes read. A pool is nil until the first + /// component of its type is assigned. + private struct ComponentPools { + let render: ComponentPool? + let world: ComponentPool? + let local: ComponentPool? + let sceneChannels: ComponentPool? + let lod: ComponentPool? + let meshOccluder: ComponentPool? + let meshFade: ComponentPool? + let tileRepresentationFade: ComponentPool? + let deformation: ComponentPool? + let tileLODTag: ComponentPool? + + init(ids: ComponentIds, scene: Scene) { + render = scene.componentPool[ids.render] + world = scene.componentPool[ids.world] + local = scene.componentPool[ids.local] + sceneChannels = scene.componentPool[ids.sceneChannels] + lod = scene.componentPool[ids.lod] + meshOccluder = scene.componentPool[ids.meshOccluder] + meshFade = scene.componentPool[ids.meshFade] + tileRepresentationFade = scene.componentPool[ids.tileRepresentationFade] + deformation = scene.componentPool[ids.deformation] + tileLODTag = scene.componentPool[ids.tileLODTag] + } + } +} + +/// The render mode of the scene channels of one entity after another. +/// +/// The entities of a scene are on a handful of channel sets, and neighbours in a list +/// are usually on the same one, so the mode is asked for again only when the channels +/// differ from those of the entity before. +struct SceneChannelRenderModeMemo { + private var channels: SceneChannel? + private var mode = SceneChannelRenderMode.normal + + @inline(__always) + mutating func mode(of entityChannels: SceneChannel) -> SceneChannelRenderMode { + if entityChannels != channels { + channels = entityChannels + mode = getSceneChannelRenderMode(entityChannels) + } + return mode + } +} diff --git a/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift b/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift index bf2982e66..fe74df537 100644 --- a/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift +++ b/Sources/UntoldEngine/Renderer/RenderVertexStreamBinding.swift @@ -29,7 +29,17 @@ import simd /// and tangent buffers and vertex-shader skinning is disabled. extension MTLRenderCommandEncoder { func bindModelVertexStreams(mesh: Mesh, entityId: EntityID) { - let deformed = deformedStreams(mesh: mesh, entityId: entityId) + bindModelVertexStreams( + mesh: mesh, + deformation: getEntityComponent(entityId: entityId, componentType: DeformationComponent.self), + hasSkeleton: Self.hasSkeleton(mesh: mesh, entityId: entityId) + ) + } + + /// For a pass that has read the entity's deformation component and knows whether it + /// has a skeleton: the entity's meshes are then bound without asking the scene again. + func bindModelVertexStreams(mesh: Mesh, deformation: DeformationComponent?, hasSkeleton: Bool) { + let deformed = deformedStreams(mesh: mesh, deformation: deformation) setVertexBuffer( deformed?.positions ?? mesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)], @@ -63,7 +73,7 @@ extension MTLRenderCommandEncoder { ) bindJointStreams( mesh: mesh, - entityId: entityId, + hasSkeleton: hasSkeleton, skinnedInCompute: deformed != nil, hasArmatureIndex: Int(modelPassHasArmature.rawValue), jointTransformIndex: Int(modelPassJointTransformIndex.rawValue) @@ -71,7 +81,16 @@ extension MTLRenderCommandEncoder { } func bindShadowVertexStreams(mesh: Mesh, entityId: EntityID) { - let deformed = deformedStreams(mesh: mesh, entityId: entityId) + bindShadowVertexStreams( + mesh: mesh, + deformation: getEntityComponent(entityId: entityId, componentType: DeformationComponent.self), + hasSkeleton: Self.hasSkeleton(mesh: mesh, entityId: entityId) + ) + } + + /// See `bindModelVertexStreams(mesh:deformation:hasSkeleton:)`. + func bindShadowVertexStreams(mesh: Mesh, deformation: DeformationComponent?, hasSkeleton: Bool) { + let deformed = deformedStreams(mesh: mesh, deformation: deformation) setVertexBuffer( deformed?.positions ?? mesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)], @@ -90,7 +109,7 @@ extension MTLRenderCommandEncoder { ) bindJointStreams( mesh: mesh, - entityId: entityId, + hasSkeleton: hasSkeleton, skinnedInCompute: deformed != nil, hasArmatureIndex: Int(shadowPassHasArmature.rawValue), jointTransformIndex: Int(shadowPassJointTransformIndex.rawValue) @@ -100,16 +119,20 @@ extension MTLRenderCommandEncoder { /// Deformed streams exist only after the deformation pass has run for /// this mesh; until then (or without a DeformationComponent) draws use /// the base streams and vertex-shader skinning. - private func deformedStreams(mesh: Mesh, entityId: EntityID) -> MeshDeformationBuffers? { - guard let component = getEntityComponent(entityId: entityId, componentType: DeformationComponent.self) else { - return nil - } - return component.meshDeformations[ObjectIdentifier(mesh.metalKitMesh)] + private func deformedStreams(mesh: Mesh, deformation: DeformationComponent?) -> MeshDeformationBuffers? { + deformation?.meshDeformations[ObjectIdentifier(mesh.metalKitMesh)] + } + + /// Whether the mesh can be skinned in the vertex shader: only a mesh with joint + /// transforms asks whether its entity has a skeleton. + private static func hasSkeleton(mesh: Mesh, entityId: EntityID) -> Bool { + mesh.skin?.jointTransformsBuffer != nil + && getEntityComponent(entityId: entityId, componentType: SkeletonComponent.self) != nil } private func bindJointStreams( mesh: Mesh, - entityId: EntityID, + hasSkeleton: Bool, skinnedInCompute: Bool, hasArmatureIndex: Int, jointTransformIndex: Int @@ -118,7 +141,7 @@ extension MTLRenderCommandEncoder { // exists and skinning did not already happen in compute. let jointTransformBuffer = mesh.skin?.jointTransformsBuffer var hasArmature = !skinnedInCompute - && getEntityComponent(entityId: entityId, componentType: SkeletonComponent.self) != nil + && hasSkeleton && jointTransformBuffer != nil setVertexBytes(&hasArmature, length: MemoryLayout.stride, index: hasArmatureIndex) diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 0fa775d8c..3a9372184 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -396,13 +396,17 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { var summary = TileRenderCostSummary() let loadedFullTiles = Set(GeometryStreamingSystem.shared.loadedTileEntitiesSnapshot()) + let sceneSnapshot = RenderSceneSnapshot() for entityId in visibleEntityIds { - guard scene.exists(entityId), - let render = scene.get(component: RenderComponent.self, for: entityId) - else { continue } + guard let entity = sceneSnapshot.entity(entityId) else { continue } + // Neither a tile's LOD mesh nor, with no full tile loaded, a child of one: + // nothing of this entity goes into the summary. + let tileLODTag = sceneSnapshot.tileLODTag(of: entity) + if tileLODTag == nil, loadedFullTiles.isEmpty { continue } + guard let render = sceneSnapshot.render(of: entity) else { continue } let cost = tileRenderCost(for: render) - if let tag = scene.get(component: TileLODTagComponent.self, for: entityId) { + if let tag = tileLODTag { if tag.levelIndex == 5 { summary.hlodVisibleInstances += 1 summary.hlodDrawsEstimate += cost.draws diff --git a/Sources/UntoldEngine/Systems/CullingSystem.swift b/Sources/UntoldEngine/Systems/CullingSystem.swift index 86eb0a96e..040abdb5d 100644 --- a/Sources/UntoldEngine/Systems/CullingSystem.swift +++ b/Sources/UntoldEngine/Systems/CullingSystem.swift @@ -637,38 +637,32 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { let frustumReadBuffer = frustumTripleBuffer.bufferForRead(frame: submitFrameIndex) - let transformId = getComponentId(for: WorldTransformComponent.self) - let renderId = getComponentId(for: RenderComponent.self) - let entities = queryEntitiesWithComponentIds([transformId, renderId], in: scene) + let sceneSnapshot = RenderSceneSnapshot() + let isXRStereoMode = renderInfo.isXRStereoMode var entityAABBContainer: [EntityAABB] = [] - entityAABBContainer.reserveCapacity(entities.count) // Pre-allocate to avoid reallocation churn + entityAABBContainer.reserveCapacity(sceneSnapshot.entityCapacity) // Pre-allocate to avoid reallocation churn let smallObjectCulling = SmallObjectCulling.forCurrentFrame() - for entityId in entities { - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { - handleError(.noRenderComponent, entityId) - continue - } + sceneSnapshot.forEachEntity(with: [.render, .worldTransform]) { entity in + let entityId = entity.entityId + guard let renderComponent = sceneSnapshot.render(of: entity), + let worldTransformComponent = sceneSnapshot.worldTransform(of: entity) + else { return } // Skip entities that are hidden (e.g., during bulk loading) if !renderComponent.isVisible { - continue - } - - guard let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId) else { - handleError(.noWorldTransformComponent, entityId) - continue + return } - guard let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) else { + guard let localTransformComponent = sceneSnapshot.localTransform(of: entity) else { handleError(.noLocalTransformComponent, entityId) - continue + return } - if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { - continue + if entity.traits.contains(.gizmo) { + return } // Objects that would be a pixel or so tall are not worth a draw. @@ -679,7 +673,7 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { worldMatrix: worldTransformComponent.space ) if smallObjectCulling.culls(center: center, radius: simd_length(halfExtent)) { - continue + return } } @@ -688,7 +682,7 @@ public func executeFrustumCulling(_ commandBuffer: MTLCommandBuffer) { // tracking drift and cause segment-level occlusion popping. The unsegmented // AABB has a conservative nearDepth (closest corner of the full entity) that // is far less likely to fall behind a stale occluder in the HZB. - if renderInfo.isXRStereoMode { + if isXRStereoMode { let singleAABB = makeObjectAABB( localMin: localTransformComponent.boundingBox.min, localMax: localTransformComponent.boundingBox.max, @@ -978,38 +972,31 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) { let frustumReadBuffer = frustumTripleBuffer.bufferForRead(frame: submitFrameIndex) - let transformId = getComponentId(for: WorldTransformComponent.self) - let renderId = getComponentId(for: RenderComponent.self) - let entities = queryEntitiesWithComponentIds([transformId, renderId], in: scene) + let sceneSnapshot = RenderSceneSnapshot() var entityAABBContainer: [EntityAABB] = [] - entityAABBContainer.reserveCapacity(entities.count) // Pre-allocate to avoid reallocation churn + entityAABBContainer.reserveCapacity(sceneSnapshot.entityCapacity) // Pre-allocate to avoid reallocation churn let smallObjectCulling = SmallObjectCulling.forCurrentFrame() - for entityId in entities { - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { - handleError(.noRenderComponent, entityId) - continue - } + sceneSnapshot.forEachEntity(with: [.render, .worldTransform]) { entity in + let entityId = entity.entityId + guard let renderComponent = sceneSnapshot.render(of: entity), + let worldTransformComponent = sceneSnapshot.worldTransform(of: entity) + else { return } // Skip entities that are hidden (e.g., during bulk loading) if !renderComponent.isVisible { - continue - } - - guard let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId) else { - handleError(.noWorldTransformComponent, entityId) - continue + return } - guard let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) else { + guard let localTransformComponent = sceneSnapshot.localTransform(of: entity) else { handleError(.noLocalTransformComponent, entityId) - continue + return } - if hasComponent(entityId: entityId, componentType: LightComponent.self) { - continue + if entity.traits.contains(.light) { + return } // Objects that would be a pixel or so tall are not worth a draw. @@ -1020,7 +1007,7 @@ func executeReduceScanFrustumCulling(_ commandBuffer: MTLCommandBuffer) { worldMatrix: worldTransformComponent.space ) if smallObjectCulling.culls(center: center, radius: simd_length(halfExtent)) { - continue + return } } diff --git a/Sources/UntoldEngine/Systems/ShadowSystem.swift b/Sources/UntoldEngine/Systems/ShadowSystem.swift index 7e497572e..27e36b306 100644 --- a/Sources/UntoldEngine/Systems/ShadowSystem.swift +++ b/Sources/UntoldEngine/Systems/ShadowSystem.swift @@ -263,7 +263,7 @@ struct ShadowSystem { /// slice. Used by the renderer to reject shadow casters that are farther than the /// engine's own shadow-distance horizon from anything this cascade could possibly /// receive — a cull that stays correct for any light direction, unlike a camera-depth - /// cutoff (see RenderPasses.shadowCasterEntityIds). + /// cutoff (see RenderPasses.shadowCasters(for:in:)). var cascadeWorldCenters: [simd_float3] = Array(repeating: .zero, count: csmCascadeCount) var cascadeWorldRadii: [Float] = Array(repeating: 0, count: csmCascadeCount) /// Camera-depth distance at which each cascade begins cross-fading into the next. @@ -590,41 +590,34 @@ struct ShadowSystem { } private func collectShadowCasterBounds() -> [ShadowCasterBounds] { - let transformId = getComponentId(for: WorldTransformComponent.self) - let localTransformId = getComponentId(for: LocalTransformComponent.self) - let renderId = getComponentId(for: RenderComponent.self) - let entities = queryEntitiesWithComponentIds([transformId, localTransformId, renderId], in: scene) + let sceneSnapshot = RenderSceneSnapshot() let batchingEnabled = BatchingSystem.shared.isEnabled() + let sceneRootMatrix = SceneRootTransform.shared.matrix + var renderModes = SceneChannelRenderModeMemo() var bounds: [ShadowCasterBounds] = [] - bounds.reserveCapacity(entities.count) - - for entityId in entities { - guard scene.mask(for: entityId) != nil else { continue } - if scene.get(component: SceneCameraComponent.self, for: entityId) != nil { continue } - if scene.get(component: CameraComponent.self, for: entityId) != nil { continue } - if scene.get(component: LightComponent.self, for: entityId) != nil { continue } - if scene.get(component: GizmoComponent.self, for: entityId) != nil { continue } - if shouldHideSceneEntity(entityId: entityId) { continue } - if shouldRenderSceneEntityAsWireframe(entityId: entityId) { continue } - if batchingEnabled, scene.get(component: StaticBatchComponent.self, for: entityId) != nil { continue } - if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entityId) { continue } - - guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId), - renderComponent.isVisible, - let worldTransformComponent = scene.get(component: WorldTransformComponent.self, for: entityId), - let localTransformComponent = scene.get(component: LocalTransformComponent.self, for: entityId) + bounds.reserveCapacity(sceneSnapshot.entityCapacity) + + sceneSnapshot.forEachEntity(with: .drawable) { entity in + if !entity.traits.isDisjoint(with: [.sceneCamera, .camera, .light, .gizmo]) { return } + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { return } + if batchingEnabled, entity.traits.contains(.staticBatch) { return } + if batchingEnabled, BatchingSystem.shared.isBatched(entityId: entity.entityId) { return } + + guard let components = sceneSnapshot.drawComponents(of: entity), + components.render.isVisible else { - continue + return } // Fold the scene-root transform into the caster's world matrix so these bounds land in // the same visual-world space as the cascade corners above -- see the comment on // `invView` in updateCascades(). - let combinedMatrix = simd_mul(SceneRootTransform.shared.matrix, worldTransformComponent.space) + let combinedMatrix = simd_mul(sceneRootMatrix, components.world.space) let (worldMin, worldMax) = worldAABB_MinMax( - localMin: localTransformComponent.boundingBox.min, - localMax: localTransformComponent.boundingBox.max, + localMin: components.local.boundingBox.min, + localMax: components.local.boundingBox.max, worldMatrix: combinedMatrix ) bounds.append(ShadowCasterBounds(min: worldMin, max: worldMax)) diff --git a/Tests/UntoldEngineRenderTests/RenderPassEntityStateTests.swift b/Tests/UntoldEngineRenderTests/RenderPassEntityStateTests.swift new file mode 100644 index 000000000..dfbbae8e6 --- /dev/null +++ b/Tests/UntoldEngineRenderTests/RenderPassEntityStateTests.swift @@ -0,0 +1,257 @@ +// +// RenderPassEntityStateTests.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Metal +import simd +@testable import UntoldEngine +import XCTest + +/// What the opaque pass decides per entity before it draws: whether the entity is still +/// there, what its scene channels ask for, and the fade its draws carry. The passes read +/// those through `RenderSceneSnapshot`; these tests pin what ends up on screen. +@MainActor +final class RenderPassEntityStateTests: BaseRenderSetup { + override func tearDown() async throws { + destroyAllEntities() + try await super.tearDown() + } + + override func initializeAssets() {} + + // MARK: - Scene helpers + + private let eye = simd_float3(0, 3, 7) + private let target = simd_float3(0, 0, 0) + + private func createTestCamera() { + let cameraEntity = createEntity() + if let cameraComponent = scene.assign(to: cameraEntity, component: CameraComponent.self) { + CameraSystem.shared.activeCamera = cameraEntity + cameraComponent.viewSpace = matrix_identity_float4x4 + cameraComponent.localPosition = .zero + } + cameraLookAt(entityId: cameraEntity, eye: eye, target: target, up: simd_float3(0, 1, 0)) + } + + /// A cube whose near face covers the whole frame. The material is emissive so the lit + /// colour does not depend on the lights or on the test IBL bake. + private func makeFrameFillingCube() -> EntityID { + let position = eye + 0.657 * (target - eye) + let entity = createEntity() + var meshes = BasicPrimitives.createCube(extent: 8.0) + let emissiveMaterial = Material( + runtimeMaterial: RuntimeMaterialSource( + baseColorFactor: simd_float4(0, 0, 0, 1), + emissiveFactor: simd_float3(0.8, 0.6, 0.4), + metallicFactor: 0.0, + roughnessFactor: 1.0 + ), + device: renderInfo.device + ) + for meshIndex in meshes.indices { + for submeshIndex in meshes[meshIndex].submeshes.indices { + meshes[meshIndex].submeshes[submeshIndex].material = emissiveMaterial + } + } + if let renderComponent = scene.assign(to: entity, component: RenderComponent.self) { + renderComponent.mesh = meshes + } + if let local = scene.get(component: LocalTransformComponent.self, for: entity) { + local.position = position + local.boundingBox = Mesh.computeMeshBoundingBox(for: meshes) + } + if let world = scene.get(component: WorldTransformComponent.self, for: entity) { + var space = matrix_identity_float4x4 + space.columns.3 = simd_float4(position, 1.0) + world.space = space + } + return entity + } + + // MARK: - Readback + + private var backgroundLit: [Float16] = [] + + private func litPixels() throws -> [Float16] { + let texture = try XCTUnwrap(textureResources.deferredColorMap) + precondition(texture.pixelFormat == .rgba16Float, "Test assumes an rgba16Float lit target") + var data = [Float16](repeating: 0, count: texture.width * texture.height * 4) + data.withUnsafeMutableBytes { bytes in + texture.getBytes( + bytes.baseAddress!, + bytesPerRow: texture.width * 8, + from: MTLRegionMake2D(0, 0, texture.width, texture.height), + mipmapLevel: 0 + ) + } + return data + } + + /// Draws a frame with `visible` as the visible list (every mesh entity when nil) and + /// returns how many pixels of the lit colour differ from the empty scene's. + private func litCoverage(visible: [EntityID]? = nil) throws -> Int { + if let visible { + visibleEntityIds = visible + for frame in 0 ..< 3 { + tripleVisibleEntities.setWrite(frame: frame, with: visible) + } + } else { + setVisibleEntities() + } + renderer.draw(in: renderer.metalView) + renderInfo.lastCommandBuffer?.waitUntilCompleted() + + let pixels = try litPixels() + if backgroundLit.isEmpty { + backgroundLit = pixels + return 0 + } + var covered = 0 + var pixel = 0 + while pixel < pixels.count { + if (0 ..< 3).contains(where: { abs(Float(pixels[pixel + $0]) - Float(backgroundLit[pixel + $0])) > 1e-3 }) { + covered += 1 + } + pixel += 4 + } + return covered + } + + /// The camera alone, kept as the "nothing drawn" reference; then the cube. + private func makeSceneWithCube() throws -> (entity: EntityID, plainCoverage: Int) { + createTestCamera() + _ = try litCoverage() + let entity = makeFrameFillingCube() + let plain = try litCoverage() + XCTAssertGreaterThan(plain, 1000, "Sanity: the cube fills the frame") + return (entity, plain) + } + + // MARK: - Scene channels + + func testAnEntityOnAHiddenChannelIsNotDrawnAndComesBack() throws { + let (entity, plain) = try makeSceneWithCube() + let channel = SceneChannel.userCustom(index: 5) + setEntitySceneChannels(entityId: entity, channels: channel) + XCTAssertGreaterThan(try litCoverage(), plain * 9 / 10, "A channel in its normal mode changes nothing") + + setSceneChannel(channel, .renderMode(.hidden)) + XCTAssertLessThan(try litCoverage(), plain / 50, "Hidden channel: nothing of the entity is drawn") + + setSceneChannel(channel, .renderMode(.normal)) + XCTAssertGreaterThan(try litCoverage(), plain * 9 / 10, "Back to normal: the entity is drawn again") + } + + func testAnEntityNamedForAHiddenChannelIsNotDrawn() throws { + // No channel component: the entity takes the channels its name asks for. + let (entity, plain) = try makeSceneWithCube() + let channel = SceneChannel.userCustom(index: 6) + registerSceneChannelPrefix("HIDE_", channels: channel) + setSceneChannel(channel, .renderMode(.hidden)) + XCTAssertGreaterThan(try litCoverage(), plain * 9 / 10, "The name does not match yet") + + setEntityName(entityId: entity, name: "HIDE_Cube") + XCTAssertNil(scene.get(component: EntitySceneChannelsComponent.self, for: entity)) + XCTAssertLessThan(try litCoverage(), plain / 50, "Renamed onto the hidden channel: not drawn") + } + + func testAnEntityOnAWireframeChannelLeavesTheSolidPass() throws { + let (entity, plain) = try makeSceneWithCube() + let channel = SceneChannel.userCustom(index: 7) + setEntitySceneChannels(entityId: entity, channels: channel) + + setSceneChannel(channel, .renderMode(.wireframe)) + let wireframe = try litCoverage() + XCTAssertLessThan(wireframe, plain / 4, "Wireframe channel: the faces are not filled (\(wireframe) of \(plain))") + + setSceneChannel(channel, .renderMode(.normal)) + XCTAssertGreaterThan(try litCoverage(), plain * 9 / 10, "Back to normal: the faces are filled again") + } + + // MARK: - Entities that are gone + + func testAnEntityWaitingToBeDestroyedIsSkippedThoughStillListedAsVisible() throws { + let (entity, plain) = try makeSceneWithCube() + XCTAssertGreaterThan(try litCoverage(visible: [entity]), plain * 9 / 10) + + destroyEntity(entityId: entity) + XCTAssertLessThan(try litCoverage(visible: [entity]), plain / 50, "Waiting to be destroyed: not drawn") + + finalizePendingDestroys() + XCTAssertLessThan(try litCoverage(visible: [entity]), plain / 50, "Destroyed, its id still in the list: not drawn") + } + + func testAnIdThatWasNeverAnEntityIsSkipped() throws { + let (entity, plain) = try makeSceneWithCube() + let unknown = createEntityId(EntityIndex(60000), 3) + + XCTAssertGreaterThan(try litCoverage(visible: [unknown, entity, unknown]), plain * 9 / 10) + } + + func testAnEntityWithoutARenderComponentIsSkipped() throws { + let (entity, plain) = try makeSceneWithCube() + let bare = createEntity() + + XCTAssertGreaterThan(try litCoverage(visible: [bare, entity]), plain * 9 / 10) + + scene.remove(component: RenderComponent.self, from: entity) + XCTAssertLessThan(try litCoverage(visible: [bare, entity]), plain / 50, "Render component removed: not drawn") + } + + // MARK: - Fades + + /// A quarter of the way, the outgoing representation of a tile keeps three quarters of + /// its pixels and the incoming one a quarter: complementary halves of one dither. + func testTileRepresentationFadeDithersTheEntityInEachDirection() throws { + let (entity, plain) = try makeSceneWithCube() + + let fade = try XCTUnwrap(scene.assign(to: entity, component: TileRepresentationFadeComponent.self)) + fade.progress = 0.25 + fade.mode = 2 + let outgoing = try Float(litCoverage()) / Float(plain) + XCTAssertEqual(outgoing, 0.75, accuracy: 0.1, "Outgoing at 0.25 keeps three quarters of the pixels, kept \(outgoing)") + + fade.mode = 1 + let incoming = try Float(litCoverage()) / Float(plain) + XCTAssertEqual(incoming, 0.25, accuracy: 0.1, "Incoming at 0.25 keeps a quarter of the pixels, kept \(incoming)") + XCTAssertEqual(outgoing + incoming, 1.0, accuracy: 0.1, "The two directions are complementary") + + scene.remove(component: TileRepresentationFadeComponent.self, from: entity) + XCTAssertGreaterThan(try litCoverage(), plain * 9 / 10, "Fade removed: the full mesh is back") + } + + /// The mesh fade is applied after the tile fade, so it decides when an entity has both. + func testMeshFadeWinsOverATileRepresentationFade() throws { + let (entity, plain) = try makeSceneWithCube() + + let tileFade = try XCTUnwrap(scene.assign(to: entity, component: TileRepresentationFadeComponent.self)) + tileFade.progress = 0.25 + tileFade.mode = 2 + let meshFade = try XCTUnwrap(scene.assign(to: entity, component: MeshFadeComponent.self)) + meshFade.direction = .fadeIn + meshFade.progress = 0.25 + + let ratio = try Float(litCoverage()) / Float(plain) + XCTAssertEqual(ratio, 0.25, accuracy: 0.1, "The mesh fade's quarter, not the tile fade's three quarters: kept \(ratio)") + } + + // MARK: - Lights, cameras and gizmos + + func testAMeshOnALightCameraOrGizmoEntityIsNotDrawn() throws { + let (entity, plain) = try makeSceneWithCube() + + for componentType in [LightComponent.self, GizmoComponent.self, SceneCameraComponent.self] as [any Component.Type] { + registerComponent(entityId: entity, componentType: componentType) + XCTAssertLessThan(try litCoverage(visible: [entity]), plain / 50, "\(componentType): the entity's mesh is not drawn") + removeEntityComponent(entityId: entity, componentType: componentType) + XCTAssertGreaterThan(try litCoverage(visible: [entity]), plain * 9 / 10, "\(componentType) removed: drawn again") + } + } +} diff --git a/Tests/UntoldEngineTests/RenderSceneSnapshotTests.swift b/Tests/UntoldEngineTests/RenderSceneSnapshotTests.swift new file mode 100644 index 000000000..f336a2734 --- /dev/null +++ b/Tests/UntoldEngineTests/RenderSceneSnapshotTests.swift @@ -0,0 +1,355 @@ +// +// RenderSceneSnapshotTests.swift +// UntoldEngineTests +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +@testable import UntoldEngine +import XCTest + +/// `RenderSceneSnapshot` must answer what the scene answers: the passes read entities +/// through it where they used to ask `scene` for each component. +@MainActor +final class RenderSceneSnapshotTests: XCTestCase { + override func setUp() async throws { + resetEngineTestState() + } + + @discardableResult + private func makeRenderEntity() -> EntityID { + let entityId = createEntity() + registerComponent(entityId: entityId, componentType: RenderComponent.self) + return entityId + } + + // MARK: - Which entities it knows + + func testAnEntityOfTheSceneIsFound() throws { + let entityId = makeRenderEntity() + + let entity = try XCTUnwrap(RenderSceneSnapshot().entity(entityId)) + + XCTAssertEqual(entity.entityId, entityId) + } + + func testAnEntityWaitingToBeDestroyedIsNotFound() { + let entityId = makeRenderEntity() + destroyEntity(entityId: entityId) + + // `scene.mask(for:)`, which the passes asked first, hides it as well. + XCTAssertNil(scene.mask(for: entityId)) + XCTAssertNil(RenderSceneSnapshot().entity(entityId)) + } + + func testADestroyedEntityIsNotFound() { + let entityId = makeRenderEntity() + destroyEntity(entityId: entityId) + finalizePendingDestroys() + + XCTAssertNil(RenderSceneSnapshot().entity(entityId)) + } + + func testTheIdOfAnEntityThatHadTheIndexBeforeIsNotFound() throws { + let first = makeRenderEntity() + destroyEntity(entityId: first) + finalizePendingDestroys() + let second = makeRenderEntity() + try XCTSkipUnless(getEntityIndex(first) == getEntityIndex(second), "The index was not handed out again") + + let snapshot = RenderSceneSnapshot() + + XCTAssertNil(snapshot.entity(first)) + XCTAssertNotNil(snapshot.entity(second)) + } + + func testAnIdPastTheEntitiesOfTheSceneIsNotFound() { + makeRenderEntity() + + XCTAssertNil(RenderSceneSnapshot().entity(createEntityId(EntityIndex(5000), 0))) + } + + func testAnEmptySceneHasNoEntities() { + let snapshot = RenderSceneSnapshot() + var visited = 0 + snapshot.forEachEntity(with: []) { _ in visited += 1 } + + XCTAssertEqual(snapshot.entityCapacity, 0) + XCTAssertEqual(visited, 0) + } + + // MARK: - Traits + + func testTraitsAreTheComponentsOfTheEntity() throws { + let plain = createEntity() + let cases: [(RenderEntityTraits, any Component.Type)] = [ + (.render, RenderComponent.self), + (.gizmo, GizmoComponent.self), + (.light, LightComponent.self), + (.camera, CameraComponent.self), + (.sceneCamera, SceneCameraComponent.self), + (.sceneChannels, EntitySceneChannelsComponent.self), + (.staticBatch, StaticBatchComponent.self), + (.meshOccluder, MeshOccluderComponent.self), + (.meshFade, MeshFadeComponent.self), + (.tileRepresentationFade, TileRepresentationFadeComponent.self), + (.lod, LODComponent.self), + (.deformation, DeformationComponent.self), + (.skeleton, SkeletonComponent.self), + (.tileLODTag, TileLODTagComponent.self), + ] + var entities: [EntityID] = [] + for (_, componentType) in cases { + let entityId = createEntity() + registerComponent(entityId: entityId, componentType: componentType) + entities.append(entityId) + } + + let snapshot = RenderSceneSnapshot() + + // createEntity gives every entity its two transforms. + let transforms: RenderEntityTraits = [.worldTransform, .localTransform] + XCTAssertEqual(try XCTUnwrap(snapshot.entity(plain)).traits, transforms) + for (index, (trait, componentType)) in cases.enumerated() { + let traits = try XCTUnwrap(snapshot.entity(entities[index])).traits + XCTAssertEqual(traits, transforms.union(trait), "\(componentType)") + } + } + + func testTraitsAgreeWithHasComponentForEveryEntity() { + let components: [(RenderEntityTraits, any Component.Type)] = [ + (.render, RenderComponent.self), + (.light, LightComponent.self), + (.lod, LODComponent.self), + (.meshFade, MeshFadeComponent.self), + (.staticBatch, StaticBatchComponent.self), + ] + var generator = SystemRandomNumberGenerator() + var entities: [EntityID] = [] + for _ in 0 ..< 200 { + let entityId = createEntity() + for (_, componentType) in components where Bool.random(using: &generator) { + registerComponent(entityId: entityId, componentType: componentType) + } + entities.append(entityId) + } + + let snapshot = RenderSceneSnapshot() + + for entityId in entities { + guard let entity = snapshot.entity(entityId) else { + XCTFail("Entity \(entityId) is missing") + continue + } + for (trait, componentType) in components { + XCTAssertEqual( + entity.traits.contains(trait), + hasComponent(entityId: entityId, componentType: componentType), + "\(componentType) of \(entityId)" + ) + } + } + } + + // MARK: - Components + + func testDrawComponentsAreTheObjectsOfTheScene() throws { + let entityId = makeRenderEntity() + + let snapshot = RenderSceneSnapshot() + let entity = try XCTUnwrap(snapshot.entity(entityId)) + let components = try XCTUnwrap(snapshot.drawComponents(of: entity)) + + XCTAssertTrue(components.render === scene.get(component: RenderComponent.self, for: entityId)) + XCTAssertTrue(components.world === scene.get(component: WorldTransformComponent.self, for: entityId)) + XCTAssertTrue(components.local === scene.get(component: LocalTransformComponent.self, for: entityId)) + XCTAssertTrue(snapshot.render(of: entity) === components.render) + XCTAssertTrue(snapshot.worldTransform(of: entity) === components.world) + XCTAssertTrue(snapshot.localTransform(of: entity) === components.local) + } + + func testAnEntityWithoutARenderComponentHasNoDrawComponents() throws { + let entityId = createEntity() + + let snapshot = RenderSceneSnapshot() + let entity = try XCTUnwrap(snapshot.entity(entityId)) + + XCTAssertNil(snapshot.drawComponents(of: entity)) + XCTAssertNil(snapshot.render(of: entity)) + XCTAssertNotNil(snapshot.worldTransform(of: entity)) + } + + func testOptionalComponentsAreTheObjectsOfTheScene() throws { + let entityId = makeRenderEntity() + registerComponent(entityId: entityId, componentType: LODComponent.self) + registerComponent(entityId: entityId, componentType: MeshOccluderComponent.self) + registerComponent(entityId: entityId, componentType: MeshFadeComponent.self) + registerComponent(entityId: entityId, componentType: TileRepresentationFadeComponent.self) + registerComponent(entityId: entityId, componentType: DeformationComponent.self) + registerComponent(entityId: entityId, componentType: TileLODTagComponent.self) + let bare = makeRenderEntity() + + let snapshot = RenderSceneSnapshot() + let entity = try XCTUnwrap(snapshot.entity(entityId)) + let bareEntity = try XCTUnwrap(snapshot.entity(bare)) + + XCTAssertTrue(snapshot.lod(of: entity) === scene.get(component: LODComponent.self, for: entityId)) + XCTAssertTrue(snapshot.meshOccluder(of: entity) === scene.get(component: MeshOccluderComponent.self, for: entityId)) + XCTAssertTrue(snapshot.meshFade(of: entity) === scene.get(component: MeshFadeComponent.self, for: entityId)) + XCTAssertTrue(snapshot.tileRepresentationFade(of: entity) === scene.get(component: TileRepresentationFadeComponent.self, for: entityId)) + XCTAssertTrue(snapshot.deformation(of: entity) === scene.get(component: DeformationComponent.self, for: entityId)) + XCTAssertTrue(snapshot.tileLODTag(of: entity) === scene.get(component: TileLODTagComponent.self, for: entityId)) + XCTAssertNil(snapshot.lod(of: bareEntity)) + XCTAssertNil(snapshot.meshOccluder(of: bareEntity)) + XCTAssertNil(snapshot.meshFade(of: bareEntity)) + XCTAssertNil(snapshot.tileRepresentationFade(of: bareEntity)) + XCTAssertNil(snapshot.deformation(of: bareEntity)) + XCTAssertNil(snapshot.tileLODTag(of: bareEntity)) + } + + func testAValueChangedAfterTheSnapshotIsSeenThroughIt() throws { + let entityId = makeRenderEntity() + let snapshot = RenderSceneSnapshot() + let entity = try XCTUnwrap(snapshot.entity(entityId)) + + scene.get(component: RenderComponent.self, for: entityId)?.isVisible = false + + XCTAssertEqual(snapshot.render(of: entity)?.isVisible, false) + } + + // MARK: - Walking the scene + + func testTheWalkVisitsTheEntitiesTheSceneQueryReturns() { + var generator = SystemRandomNumberGenerator() + var entities: [EntityID] = [] + for _ in 0 ..< 300 { + entities.append(Bool.random(using: &generator) ? makeRenderEntity() : createEntity()) + } + // Holes, entities waiting to be destroyed, and indices handed out again. + for entityId in entities where Int.random(in: 0 ..< 4, using: &generator) == 0 { + destroyEntity(entityId: entityId) + } + finalizePendingDestroys() + for _ in 0 ..< 40 { + entities.append(makeRenderEntity()) + } + for entityId in entities.suffix(10) { + destroyEntity(entityId: entityId) + } + + var visited: [EntityID] = [] + RenderSceneSnapshot().forEachEntity(with: .drawable) { visited.append($0.entityId) } + + let expected = queryEntitiesWithComponentIds( + [ + getComponentId(for: RenderComponent.self), + getComponentId(for: WorldTransformComponent.self), + getComponentId(for: LocalTransformComponent.self), + ], + in: scene + ) + XCTAssertEqual(Set(visited), Set(expected)) + XCTAssertEqual(visited.count, expected.count) + XCTAssertFalse(visited.isEmpty) + XCTAssertEqual(visited.map(getEntityIndex), visited.map(getEntityIndex).sorted(), "The walk follows the entity indices") + } + + func testTheWalkWithNoTraitsVisitsEveryLiveEntity() { + let kept = [createEntity(), makeRenderEntity(), createEntity()] + let destroyed = createEntity() + destroyEntity(entityId: destroyed) + + var visited: [EntityID] = [] + RenderSceneSnapshot().forEachEntity(with: []) { visited.append($0.entityId) } + + XCTAssertEqual(visited, kept) + XCTAssertEqual(Set(visited), Set(scene.getAllEntities())) + } + + // MARK: - A snapshot is the scene at one moment + + func testASnapshotKeepsTheComponentsItSaw() throws { + let entityId = makeRenderEntity() + let snapshot = RenderSceneSnapshot() + + scene.remove(component: RenderComponent.self, from: entityId) + let later = makeRenderEntity() + + let entity = try XCTUnwrap(snapshot.entity(entityId)) + XCTAssertTrue(entity.traits.contains(.render)) + XCTAssertNotNil(snapshot.drawComponents(of: entity)) + XCTAssertNil(snapshot.entity(later), "An entity made after the snapshot is not in it") + + let next = RenderSceneSnapshot() + XCTAssertFalse(try XCTUnwrap(next.entity(entityId)).traits.contains(.render)) + XCTAssertNotNil(next.entity(later)) + } + + // MARK: - Scene channels + + func testSceneChannelsComeFromTheChannelComponent() throws { + let entityId = makeRenderEntity() + setEntitySceneChannels(entityId: entityId, channels: [.ghostGeometry, .userCustom(index: 3)]) + + let snapshot = RenderSceneSnapshot() + let entity = try XCTUnwrap(snapshot.entity(entityId)) + + XCTAssertEqual(snapshot.sceneChannels(of: entity), [.ghostGeometry, .userCustom(index: 3)]) + XCTAssertEqual(snapshot.sceneChannels(of: entity), getEntitySceneChannels(entityId: entityId)) + } + + func testAnEntityWithoutTheChannelComponentGetsTheChannelsOfItsName() throws { + let context = makeRenderEntity() + let selectable = makeRenderEntity() + setEntityName(entityId: selectable, name: "\(selectableSceneEntityNamePrefix)Door") + let notRenderable = createEntity() + + let snapshot = RenderSceneSnapshot() + + for entityId in [context, selectable, notRenderable] { + let entity = try XCTUnwrap(snapshot.entity(entityId)) + XCTAssertFalse(entity.traits.contains(.sceneChannels)) + XCTAssertEqual(snapshot.sceneChannels(of: entity), getEntitySceneChannels(entityId: entityId)) + } + try XCTAssertEqual(snapshot.sceneChannels(of: XCTUnwrap(snapshot.entity(context))), .contextGeometry) + try XCTAssertEqual(snapshot.sceneChannels(of: XCTUnwrap(snapshot.entity(selectable))), [.selectableGeometry, .preserveIdentity]) + try XCTAssertEqual(snapshot.sceneChannels(of: XCTUnwrap(snapshot.entity(notRenderable))), []) + } + + func testAChannelChangedAfterTheSnapshotIsSeenThroughIt() throws { + let entityId = makeRenderEntity() + setEntitySceneChannels(entityId: entityId, channels: .contextGeometry) + let snapshot = RenderSceneSnapshot() + let entity = try XCTUnwrap(snapshot.entity(entityId)) + + setEntitySceneChannels(entityId: entityId, channels: .ghostGeometry) + + XCTAssertEqual(snapshot.sceneChannels(of: entity), .ghostGeometry) + } + + // MARK: - Render mode memo + + func testTheMemoGivesTheRenderModeOfEachChannelSet() { + setSceneChannel(.contextGeometry, .renderMode(.hidden)) + setSceneChannel(.ghostGeometry, .renderMode(.passthroughGhost(opacity: 0.25))) + setSceneChannel(.userCustom(index: 1), .renderMode(.wireframe)) + var memo = SceneChannelRenderModeMemo() + + let sequence: [SceneChannel] = [ + .contextGeometry, .contextGeometry, .ghostGeometry, [], .userCustom(index: 1), + .selectableGeometry, [.ghostGeometry, .userCustom(index: 1)], .contextGeometry, [], + ] + for channels in sequence { + XCTAssertEqual(memo.mode(of: channels), getSceneChannelRenderMode(channels), "\(channels)") + } + } + + func testAMemoWithNoEntityBeforeAsksForTheModeOfTheEmptySet() { + var memo = SceneChannelRenderModeMemo() + + XCTAssertEqual(memo.mode(of: []), .normal) + } +} diff --git a/Tests/UntoldEngineTests/ShadowCascadeTests.swift b/Tests/UntoldEngineTests/ShadowCascadeTests.swift index e5f2d1bc2..21f1b17d9 100644 --- a/Tests/UntoldEngineTests/ShadowCascadeTests.swift +++ b/Tests/UntoldEngineTests/ShadowCascadeTests.swift @@ -50,7 +50,7 @@ final class MaxShadowCastingDistanceAPITests: XCTestCase { func testValueIsClampedToAPositiveMinimum() { // A zero or negative distance would make the shadow-distance reject in - // RenderPasses.shadowCasterEntityIds degenerate (shadowEntityBeyondMaxDistance treats + // RenderPasses.shadowCasters(for:in:) degenerate (shadowEntityBeyondMaxDistance treats // maxDistance <= 0 as "cull nothing", so this guards against accidentally disabling the // cull entirely rather than tightening it). setRendering(.maxShadowCastingDistance(-5.0)) @@ -356,7 +356,7 @@ final class ShadowSystemCascadeNearDistanceTests: XCTestCase { // MARK: - Cascade world-space caster reach -/// Tests for the direction-agnostic distance reject in RenderPasses.shadowCasterEntityIds: +/// Tests for the direction-agnostic distance reject in RenderPasses.shadowCasters(for:in:): /// a caster farther than `cascadeWorldRadii[i] + maxShadowCastingDistance` from /// `cascadeWorldCenters[i]` cannot matter to that cascade regardless of light direction, /// so it is safe to drop before the more expensive light-space frustum test. diff --git a/Tests/UntoldEngineTests/ShadowDistanceCullingTests.swift b/Tests/UntoldEngineTests/ShadowDistanceCullingTests.swift index dd853ac24..c6426ac65 100644 --- a/Tests/UntoldEngineTests/ShadowDistanceCullingTests.swift +++ b/Tests/UntoldEngineTests/ShadowDistanceCullingTests.swift @@ -12,7 +12,7 @@ import simd @testable import UntoldEngine import XCTest -/// Tests for the shadow distance culling helper used in RenderPasses.shadowCasterEntityIds. +/// Tests for the shadow distance culling helper used in RenderPasses.shadowCasters(for:in:). /// All tests exercise shadowEntityBeyondMaxDistance() directly — no Metal or scene state required. @MainActor final class ShadowDistanceCullingTests: XCTestCase { diff --git a/docs/Architecture/renderingSystem.md b/docs/Architecture/renderingSystem.md index d52727b71..9aac362fc 100644 --- a/docs/Architecture/renderingSystem.md +++ b/docs/Architecture/renderingSystem.md @@ -51,7 +51,7 @@ These four compute dispatches run **before any render encoder is opened**. They ### 3a. Frustum Culling → `performFrustumCulling(commandBuffer:)` -A compute shader tests every entity's axis-aligned bounding box (`EntityAABB`) against the camera's 6 frustum planes. Entities outside the frustum are excluded. +A compute shader tests every entity's axis-aligned bounding box (`EntityAABB`) against the camera's 6 frustum planes. Entities outside the frustum are excluded. The boxes are gathered on the CPU from the entities that have a render component and a world transform, walked in the order of their indices (see [How a Pass Reads the Scene](#how-a-pass-reads-the-scene)). The result is written into `tripleVisibleEntities` — **for the next frame**. So culling is always one frame behind rendering. This is an intentional latency trade-off: GPU-driven culling is far faster than CPU culling, and one frame of lag is imperceptible. @@ -208,7 +208,7 @@ Inside `combinedModelLightExecution`, the unbatched model phase iterates `visibl - Uploads the model matrix, normal matrix, and camera uniforms into the current in-flight frame slot - Issues a draw call per mesh submesh -Before encoding each draw, the renderer checks scene-channel visibility. Individual entities use `shouldHideSceneEntity(entityId:)`; batch groups use their stored channel mask. Hidden channels are skipped entirely rather than rendered transparently. +Before encoding an entity's draws, the renderer checks scene-channel visibility. Individual entities take the render mode of their channels through the pass's scene snapshot (see [How a Pass Reads the Scene](#how-a-pass-reads-the-scene)); batch groups use their stored channel mask. Hidden channels are skipped entirely rather than rendered transparently. The batched opaque phase uses **cluster-level frustum culling**: it calls `visibleBatchGroupsSnapshot()` which tests each `BatchGroup`'s precomputed world-space AABB against `currentFrameFrustum` using `isAABBInFrustum`, then filters by scene-channel visibility. The result — groups whose AABB intersects the frustum and whose channels are visible — is cached for the frame and shared with later batch-aware passes. Opaque groups are submitted as a single draw call with their merged vertex and index buffers. @@ -361,6 +361,36 @@ Tone maps the HDR scene color into the display's color space (SDR or EDR dependi --- +## How a Pass Reads the Scene + +The culling gather and the geometry passes (shadow, model, transparency, wireframe, occluder shell) ask the same few things about thousands of entities: is the entity still there, which components does it have, where are its render and transform components. Asking `scene` each time costs a lock, a copy of the scene and a component-id lookup per question, and with tens of thousands of entities that was most of the frame. + +A pass therefore builds a `RenderSceneSnapshot` when it starts and reads entities through it: + +```swift +let sceneSnapshot = RenderSceneSnapshot() +var renderModes = SceneChannelRenderModeMemo() + +for entityId in visibleEntityIds { + guard let entity = sceneSnapshot.entity(entityId) else { continue } // gone or waiting to be destroyed + let renderMode = renderModes.mode(of: sceneSnapshot.sceneChannels(of: entity)) + if renderMode == .hidden || renderMode == .wireframe { continue } + if !entity.traits.isDisjoint(with: [.camera, .light, .gizmo]) { continue } + guard let components = sceneSnapshot.drawComponents(of: entity) else { continue } + // components.render, components.world, components.local +} +``` + +- **One read of the scene.** The snapshot takes the entity list and the storage of the components the passes use, and looks their ids up once. `entity(_:)` checks the id against that list; `entity.traits` (`RenderEntityTraits`) says which components the entity has, from its component mask. +- **The scene's own objects.** `drawComponents(of:)`, `lod(of:)`, `meshFade(of:)` and the rest hand out the component objects of the scene, so their values are always current. Only which entities exist and which components they have is fixed for the life of the snapshot: a snapshot is built per pass and dropped when the pass ends. +- **Walks follow the entity indices.** `forEachEntity(with:)` visits the entities that have a set of components in the order of their indices. The culling gather uses it, so the list sent to the GPU cull, and with it the draw order, is the same from one run to the next. +- **Channel modes are asked once per channel set.** `SceneChannelRenderModeMemo` asks for the render mode of a channel set only when it differs from the one of the entity before. +- **What every draw shares is read when the pass starts**: camera position, projection, quality settings and debug switches (`OpaquePassState` for the model passes). What every draw of an entity shares (its fades, its deformation buffers, its ghost opacity) is read once per entity. + +When a pass needs another component, add it to the snapshot instead of calling `scene.get` inside the loop. + +--- + ## Step 5: Graph Execution With the graph assembled, the engine sorts and executes it: From 6446393ca6ed9f285fe9d35de207c1b45465795e Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Thu, 8 Oct 2026 21:05:51 +0200 Subject: [PATCH 05/11] [Feature] Select LOD levels by the size of the entity on screen (#1322) * [Feature] Select LOD levels by the size of the entity on screen The LOD chain of a pack model gives each level a screen size, and the loader turned it into a distance for each placement as the pack loaded: right for the scale and the field of view of that moment only. A placement scaled afterwards, or a zoom, kept switching at the old distances. A LOD component can now select by screen size (LODComponent.selectsByScreenSize): each level ends at the distance where the entity covers the screenPercentage of the next one. The size is read as each LOD pass runs, from the sphere around the entity's bounds under its world scale and from the projection in use. It is a share of the viewport height whatever the resolution, so a denser display draws the same levels. The bias and the hysteresis act on these distances as they do on maxDistance. The placements of a pack select this way. The parts of a model with several nodes carry the radius of the whole model (LODComponent.screenSizeRadius) and change level together. The distance of the load stays in maxDistance: it serves an orthographic view, and a level without a screen size. LODLevel.screenPercentage was stored and never read. A component selects by it only when told to, so entities that were given distances keep them. * [Patch] In stereo, take the LOD reach from the eyes' projections of the last frame The LOD update runs before the eyes are rendered, so in stereo the reach it took from renderInfo.perspectiveSpace was the second eye's of the previous frame, and on the first pass of a headset session the window's projection. The update now takes, in stereo, the larger of the two eye projections' reaches from the frame rendered last, and until an eye has been rendered (the projections are still the identity) the stored distances apply, as under an orthographic projection. A headset's field of view does not change between frames. --------- Co-authored-by: Javier Segura --- Sources/UntoldEngine/ECS/Components.swift | 19 +- Sources/UntoldEngine/Systems/LODSystem.swift | 171 +++++++++++++-- .../Systems/RegistrationSystem.swift | 51 +++-- .../UntoldPackLODRenderTests.swift | 113 ++++++++++ .../LODScreenSizeTests.swift | 201 ++++++++++++++++++ docs/API/UsingLODSystem.md | 61 +++++- docs/Architecture/lodSystem.md | 12 +- 7 files changed, 577 insertions(+), 51 deletions(-) create mode 100644 Tests/UntoldEngineTests/LODScreenSizeTests.swift diff --git a/Sources/UntoldEngine/ECS/Components.swift b/Sources/UntoldEngine/ECS/Components.swift index f6f124d42..62d7a7466 100644 --- a/Sources/UntoldEngine/ECS/Components.swift +++ b/Sources/UntoldEngine/ECS/Components.swift @@ -872,7 +872,11 @@ protocol LODDistanceLevel { public struct LODLevel { public var mesh: [Mesh] // Meshes for this lod public var maxDistance: Float // Switch to next LOD beyond this - public var screenPercentage: Float // Optional: screen-space threshold + /// The size on screen at which this level is detailed enough to take over from the + /// one before it: the share of the viewport height (1 is the whole height) that + /// the sphere around the entity covers. Read when the component selects by screen + /// size (`LODComponent.selectsByScreenSize`); 0 leaves the switch to `maxDistance`. + public var screenPercentage: Float public var url: URL? // URL to the LOD file (for streaming reload) public var assetName: String? // Mesh name within the file (for streaming reload) public var residencyState: LODResidencyState = .unknown // Streaming state @@ -918,6 +922,19 @@ public class LODComponent: Component { /// one level is drawn stays when another takes its place. public var levelsShareMaterials: Bool = false + /// True when the levels are chosen by the size of the entity on screen instead of + /// by distances fixed beforehand: a level takes over when the sphere around the + /// entity covers no more than the level's `screenPercentage`. The size follows the + /// entity's scale and the field of view, so the switch stays where the entity looks + /// as large as when the level was made for it. A level without a screen size, and + /// any view without perspective, falls back to `maxDistance`. + public var selectsByScreenSize: Bool = false + + /// The radius of that sphere in the entity's own space. 0 measures the entity's + /// bounding box. A part of a larger model carries the radius of the whole model, so + /// that all its parts change level together. + public var screenSizeRadius: Float = 0 + public required init() {} /// Check if the desired LOD level has a resident mesh diff --git a/Sources/UntoldEngine/Systems/LODSystem.swift b/Sources/UntoldEngine/Systems/LODSystem.swift index 811d8d041..681344fe4 100644 --- a/Sources/UntoldEngine/Systems/LODSystem.swift +++ b/Sources/UntoldEngine/Systems/LODSystem.swift @@ -56,13 +56,20 @@ public class LODSystem: @unchecked Sendable { hasRunOnce = true lastCameraPosition = cameraPosition + // What a size on screen is worth in distance, for the view being drawn. In + // stereo the eyes are rendered after this update, so their projections are the + // last frame's; the field of view of a headset does not change between frames. + let screenSizeReach = renderInfo.isXRStereoMode + ? lodScreenSizeReach(eyeProjections: [renderInfo.xrEye0Projection, renderInfo.xrEye1Projection]) + : lodScreenSizeReach(projection: renderInfo.perspectiveSpace) + // Query entities with LOD components let lodId = getComponentId(for: LODComponent.self) let transformId = getComponentId(for: WorldTransformComponent.self) let entities = queryEntitiesWithComponentIds([lodId, transformId], in: scene) for entityId in entities { - updateEntityLOD(entityId: entityId, cameraPosition: cameraPosition, config: config) + updateEntityLOD(entityId: entityId, cameraPosition: cameraPosition, screenSizeReach: screenSizeReach, config: config) } } @@ -105,7 +112,7 @@ public class LODSystem: @unchecked Sendable { } } - private func updateEntityLOD(entityId: EntityID, cameraPosition: simd_float3, config: LODConfig) { + private func updateEntityLOD(entityId: EntityID, cameraPosition: simd_float3, screenSizeReach: Float?, config: LODConfig) { guard let lodComponent = scene.get(component: LODComponent.self, for: entityId) else { return } // Skip if no LOD levels loaded yet (async loading may still be in progress) @@ -118,16 +125,36 @@ public class LODSystem: @unchecked Sendable { return } - // Calculate distance - let distance = entityDistanceToCamera(entityId: entityId, cameraPosition: cameraPosition) - - // Select desired LOD level based on distance - let desiredLOD = selectLODLevel( - distance: distance, - lodComponent: lodComponent, - currentLOD: lodComponent.desiredLOD, - config: config - ) + let desiredLOD: Int + if lodComponent.selectsByScreenSize, let screenSizeReach { + // Select by the size of the entity on screen, as it is now + let (distance, radius) = entityDistanceAndRadius( + entityId: entityId, + cameraPosition: cameraPosition, + localRadius: lodComponent.screenSizeRadius + ) + desiredLOD = selectLODIndex( + levels: lodComponent.lodLevels, + distance: distance, + reach: radius * screenSizeReach, + currentLOD: lodComponent.desiredLOD, + forcedLOD: lodComponent.forcedLOD, + lodBias: config.lodBias, + hysteresis: config.hysteresis, + globalDistances: config.lodDistances + ) + } else { + // Calculate distance + let distance = entityDistanceToCamera(entityId: entityId, cameraPosition: cameraPosition) + + // Select desired LOD level based on distance + desiredLOD = selectLODLevel( + distance: distance, + lodComponent: lodComponent, + currentLOD: lodComponent.desiredLOD, + config: config + ) + } lodComponent.desiredLOD = desiredLOD @@ -309,6 +336,47 @@ func entityDistanceToCamera(entityId: EntityID, cameraPosition: simd_float3) -> return simd_distance(cameraPosition, simd_float3(worldCenter.x, worldCenter.y, worldCenter.z)) } +/// The distance from the camera to the center of an entity's bounds and the radius, in +/// world space, of the sphere its size on screen is measured by: `localRadius` under the +/// entity's world transform, or the sphere around its bounding box when `localRadius` +/// is 0. +func entityDistanceAndRadius(entityId: EntityID, cameraPosition: simd_float3, localRadius: Float) -> (distance: Float, radius: Float) { + guard let worldTransform = scene.get(component: WorldTransformComponent.self, for: entityId), + let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) + else { return (0.0, 0.0) } + + let boundingBox = localTransform.boundingBox + let localCenter = (boundingBox.min + boundingBox.max) * 0.5 + let worldCenter = worldTransform.space * simd_float4(localCenter, 1.0) + let radius = localRadius > 0 ? localRadius : simd_length(boundingBox.max - boundingBox.min) * 0.5 + return ( + simd_distance(cameraPosition, simd_float3(worldCenter.x, worldCenter.y, worldCenter.z)), + radius * largestAxisScale(of: worldTransform.space) + ) +} + +/// The distance, per unit of radius, at which a sphere covers a screen size of 1 (the +/// whole height of the viewport) in the view that `projection` draws: a sphere of radius +/// r covers the screen size s at the distance r * reach / s. Nil when the projection has +/// no perspective, where the size on screen does not depend on the distance. +/// +/// A screen size is a share of the viewport height, whatever that height is in pixels: +/// the switches follow the field of view and not the resolution, so a denser display +/// draws the same levels as a coarser one. +func lodScreenSizeReach(projection: simd_float4x4) -> Float? { + // A perspective projection has no constant term in w; its [1][1] is 1 / tan(fovY / 2). + let perspectiveScale = projection.columns.1.y + guard projection.columns.3.w == 0, perspectiveScale > 0, perspectiveScale.isFinite else { return nil } + return perspectiveScale +} + +/// The reach for a stereo frame: the larger of the eyes' reaches, so that an entity is +/// drawn at the level the eye that sees it largest asks for. Nil until an eye has been +/// rendered (the projections are still the identity), when the stored distances serve. +func lodScreenSizeReach(eyeProjections: [simd_float4x4]) -> Float? { + eyeProjections.compactMap(lodScreenSizeReach(projection:)).max() +} + /// The share of a level's switch distance that `selectLODIndex` lets the hysteresis reach. let lodHysteresisDistanceShare: Float = 0.1 @@ -341,22 +409,79 @@ func selectLODIndex( lodBias: Float, hysteresis: Float, globalDistances: [Float] +) -> Int { + selectLODIndex( + levelCount: levels.count, + distance: distance, + currentLOD: currentLOD, + forcedLOD: forcedLOD, + lodBias: lodBias, + hysteresis: hysteresis + ) { index in + lodDistanceThreshold(of: levels[index], at: index, globalDistances: globalDistances) + } +} + +/// `selectLODIndex` for an entity whose levels are chosen by its size on screen +/// (`LODComponent.selectsByScreenSize`). A level ends at the distance where the entity +/// covers the screen size of the next one, `reach / screenPercentage`, with `reach` the +/// distance at which the entity covers a screen size of 1: its world radius times +/// `lodScreenSizeReach`. The bias and the hysteresis act on that distance as they do on +/// a `maxDistance`. A level whose successor has no screen size, the last level, and an +/// entity without a size end at their own `maxDistance` instead. +func selectLODIndex( + levels: [LODLevel], + distance: Float, + reach: Float, + currentLOD: Int, + forcedLOD: Int?, + lodBias: Float, + hysteresis: Float, + globalDistances: [Float] +) -> Int { + selectLODIndex( + levelCount: levels.count, + distance: distance, + currentLOD: currentLOD, + forcedLOD: forcedLOD, + lodBias: lodBias, + hysteresis: hysteresis + ) { index in + if reach > 0, reach.isFinite, index + 1 < levels.count, levels[index + 1].screenPercentage > 0 { + return reach / levels[index + 1].screenPercentage + } + return lodDistanceThreshold(of: levels[index], at: index, globalDistances: globalDistances) + } +} + +/// Where a level hands over to the next one by distance: its own `maxDistance`, or +/// `globalDistances[index]` when that is unset (0). Nil when neither says. +private func lodDistanceThreshold(of level: some LODDistanceLevel, at index: Int, globalDistances: [Float]) -> Float? { + if level.maxDistance > 0 { + return level.maxDistance + } + return index < globalDistances.count ? globalDistances[index] : nil +} + +/// The walk behind `selectLODIndex`: the first level, finest first, whose +/// `switchDistance` the biased distance has not passed. A level without one is skipped. +private func selectLODIndex( + levelCount: Int, + distance: Float, + currentLOD: Int, + forcedLOD: Int?, + lodBias: Float, + hysteresis: Float, + switchDistance: (Int) -> Float? ) -> Int { if let forced = forcedLOD, forced >= 0 { - return min(forced, levels.count - 1) + return min(forced, levelCount - 1) } let adjustedDistance = distance * lodBias - for (index, level) in levels.enumerated() { - let baseThreshold: Float - if level.maxDistance > 0 { - baseThreshold = level.maxDistance - } else if index < globalDistances.count { - baseThreshold = globalDistances[index] - } else { - continue - } + for index in 0 ..< levelCount { + guard let baseThreshold = switchDistance(index) else { continue } let threshold = index < currentLOD ? baseThreshold - min(hysteresis, baseThreshold * lodHysteresisDistanceShare) @@ -366,5 +491,5 @@ func selectLODIndex( } } - return levels.count - 1 + return levelCount - 1 } diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index c9ad0f947..e093742cc 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -1098,11 +1098,18 @@ private func registerUntoldRuntimeAsset( prebuiltMeshes: prebuiltMeshes[node.id] ) if didRegisterPayload, !lodLevels.isEmpty { + // The parts of a model change level together, at the size of the whole + // model: each carries the model's radius in its own space. A model that is + // one node is measured by its own bounds. + let modelRadius = boundingRadius(of: runtimeAsset.worldBounds) + let nodeScale = largestAxisScale(of: node.worldTransform) + let isPartOfModel = targetEntityId != entityId && nodeScale > 0 && nodeScale.isFinite registerPackLODLevels( entityId: targetEntityId, node: node, modelURL: url, - worldRadius: boundingRadius(of: runtimeAsset.worldBounds) * lodPlacementScale, + worldRadius: modelRadius * lodPlacementScale, + screenSizeRadius: isPartOfModel ? modelRadius / nodeScale : 0, levels: lodLevels ) } @@ -2626,15 +2633,22 @@ func lodSwitchDistance(radius: Float, screenSize: Float, fovYDegrees: Float) -> /// its own meshes (level 0). The meshes of a level pair with the node's by position /// and take their materials, so a level is the same surface with fewer triangles. /// -/// Each level's screen size becomes a switch distance for this placement: the distance -/// at which the model's bounding sphere (`worldRadius`) covers that share of the -/// viewport under the engine's field of view. A small prop and a large tree with the -/// same chain therefore switch at distances that suit each. +/// The levels are chosen by the size of the placement on screen, read as each frame is +/// drawn (`LODComponent.selectsByScreenSize`), so a small prop and a large tree with +/// the same chain switch at distances that suit each, and keep doing so when the +/// placement is scaled or the field of view changes. +/// `screenSizeRadius` is the radius that size is measured by, in the entity's own +/// space (0 for the entity's bounding box). +/// +/// Each level also gets the distance the same switch falls at as the pack loads: where +/// the model's bounding sphere (`worldRadius`) covers the level's screen size under the +/// engine's field of view. It serves a view without perspective. private func registerPackLODLevels( entityId: EntityID, node: RuntimeAssetNode, modelURL: URL, worldRadius: Float, + screenSizeRadius: Float, levels: [PackLODLevelMeshes] ) { guard worldRadius > 0, let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { @@ -2643,6 +2657,9 @@ private func registerPackLODLevels( let modelMeshes = renderComponent.mesh var usable: [(screenSize: Float, url: URL, meshes: [Mesh])] = [] + // A level never takes over at a larger size than the level before it, whatever the + // manifest says. + var previousScreenSize = Float.greatestFiniteMagnitude for level in levels { guard var meshes = level.meshesByNode[node.id], meshes.count == modelMeshes.count, @@ -2655,23 +2672,23 @@ private func registerPackLODLevels( meshes[meshIndex].localSpace = modelMeshes[meshIndex].localSpace meshes[meshIndex].worldSpace = modelMeshes[meshIndex].worldSpace } - usable.append((level.screenSize, level.url, meshes)) + previousScreenSize = min(level.screenSize, previousScreenSize) + usable.append((previousScreenSize, level.url, meshes)) } guard let first = usable.first else { return } // A level's maxDistance is where the next one takes over; the last has no end. var lodLevels: [LODLevel] = [] - var previousDistance = lodSwitchDistance(radius: worldRadius, screenSize: first.screenSize, fovYDegrees: fov) - lodLevels.append(LODLevel(mesh: modelMeshes, maxDistance: previousDistance, url: modelURL, assetName: node.name)) + lodLevels.append(LODLevel( + mesh: modelMeshes, + maxDistance: lodSwitchDistance(radius: worldRadius, screenSize: first.screenSize, fovYDegrees: fov), + url: modelURL, + assetName: node.name + )) for (index, level) in usable.enumerated() { var maxDistance = Float.greatestFiniteMagnitude if index + 1 < usable.count { - // Never closer than the level before, whatever the manifest says. - maxDistance = max( - lodSwitchDistance(radius: worldRadius, screenSize: usable[index + 1].screenSize, fovYDegrees: fov), - previousDistance - ) - previousDistance = maxDistance + maxDistance = lodSwitchDistance(radius: worldRadius, screenSize: usable[index + 1].screenSize, fovYDegrees: fov) } lodLevels.append(LODLevel( mesh: level.meshes, @@ -2683,7 +2700,11 @@ private func registerPackLODLevels( } configureLODComponent(entityId: entityId, lodLevels: lodLevels, activeLODIndex: 0) - scene.get(component: LODComponent.self, for: entityId)?.levelsShareMaterials = true + if let lodComponent = scene.get(component: LODComponent.self, for: entityId) { + lodComponent.levelsShareMaterials = true + lodComponent.selectsByScreenSize = true + lodComponent.screenSizeRadius = screenSizeRadius + } } /// A `.untold` parsed and built into GPU meshes, ready for any number of entities to diff --git a/Tests/UntoldEngineRenderTests/UntoldPackLODRenderTests.swift b/Tests/UntoldEngineRenderTests/UntoldPackLODRenderTests.swift index bde2a6048..6440a00fa 100644 --- a/Tests/UntoldEngineRenderTests/UntoldPackLODRenderTests.swift +++ b/Tests/UntoldEngineRenderTests/UntoldPackLODRenderTests.swift @@ -298,6 +298,119 @@ final class UntoldPackLODRenderTests: BaseRenderSetup { } } + // MARK: - Switching by the size on screen + + /// The projection the tests draw with, for another field of view. + private func projection(fovYDegrees: Float) -> simd_float4x4 { + matrixPerspectiveRightHandReverseZ( + fovyRadians: degreesToRadians(degrees: fovYDegrees), + aspectRatio: Float(windowWidth) / Float(windowHeight), + nearZ: near, + farZ: far + ) + } + + func testAPlacementSelectsItsLevelsByItsSizeOnScreen() async throws { + let packURL = try writePack([ + Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold"), + Placement(name: "Stadium", asset: "stadium", path: "Stadium/Stadium.untold", position: SIMD3(300, 0, 0), scale: 2), + ]) + let (_, children) = try await loadPack(packURL) + traverseSceneGraph() + + // A model of one node is measured by its own bounds. + let ball = try lod(XCTUnwrap(children["Ball"])) + XCTAssertTrue(ball.selectsByScreenSize) + XCTAssertEqual(ball.screenSizeRadius, 0) + + // The parts of a model carry the radius of the whole model. + let model = try NativeFormatLoader().loadAssetSync(from: tempRoot.appendingPathComponent("Stadium/Stadium.untold")) + let stadium = try XCTUnwrap(children["Stadium"]) + let nodes = try XCTUnwrap(scene.get(component: ScenegraphComponent.self, for: stadium)).children + .filter { hasComponent(entityId: $0, componentType: RenderComponent.self) } + XCTAssertEqual(nodes.count, 7) + for node in nodes { + let component = try lod(node) + XCTAssertTrue(component.selectsByScreenSize) + let measured = entityDistanceAndRadius(entityId: node, cameraPosition: .zero, localRadius: component.screenSizeRadius) + let expected = boundingRadius(of: model.worldBounds) * 2 + XCTAssertEqual(measured.radius, expected, accuracy: expected * 1e-3, getEntityName(entityId: node)) + } + } + + func testAPlacementScaledAfterTheLoadSwitchesAtItsNewSize() async throws { + let packURL = try writePack([Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold")]) + let (_, children) = try await loadPack(packURL) + let ball = try XCTUnwrap(children["Ball"]) + let component = try lod(ball) + let camera = makeCamera() + let firstSwitch = component.lodLevels[0].maxDistance + + selectLOD(for: ball, cameraAt: firstSwitch * 1.4, camera: camera) + XCTAssertEqual(component.currentLOD, 1, "past the first switch") + + // Three times as large, the ball covers from there more than it did at the switch. + scaleTo(entityId: ball, scale: simd_float3(repeating: 3)) + selectLOD(for: ball, cameraAt: firstSwitch * 1.4, camera: camera) + XCTAssertEqual(component.currentLOD, 0) + + selectLOD(for: ball, cameraAt: firstSwitch * 3.2, camera: camera) + XCTAssertEqual(component.currentLOD, 1, "the switch moved out three times") + XCTAssertEqual(component.lodLevels[0].maxDistance, firstSwitch, "the distance of the load stays as it was written") + } + + func testTheFieldOfViewMovesTheSwitchesAndTheResolutionDoesNot() async throws { + let packURL = try writePack([Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold")]) + let (_, children) = try await loadPack(packURL) + let ball = try XCTUnwrap(children["Ball"]) + let component = try lod(ball) + let camera = makeCamera() + let firstSwitch = component.lodLevels[0].maxDistance + let savedProjection = renderInfo.perspectiveSpace + let savedViewPort = renderInfo.viewPort + defer { + renderInfo.perspectiveSpace = savedProjection + renderInfo.viewPort = savedViewPort + } + + // Half the field of view magnifies by tan(fov / 2) / tan(fov / 4), a little over two. + renderInfo.perspectiveSpace = projection(fovYDegrees: fov / 2) + let zoom = tan(degreesToRadians(degrees: fov) / 2) / tan(degreesToRadians(degrees: fov) / 4) + selectLOD(for: ball, cameraAt: firstSwitch * zoom * 0.95, camera: camera) + XCTAssertEqual(component.currentLOD, 0) + selectLOD(for: ball, cameraAt: firstSwitch * zoom * 1.15, camera: camera) + XCTAssertEqual(component.currentLOD, 1) + + // Twice the lines under the field of view of the load: the ball covers the same + // share of the viewport, and switches where it did. + renderInfo.perspectiveSpace = savedProjection + renderInfo.viewPort = simd_float2(Float(windowWidth) * 2, Float(windowHeight) * 2) + selectLOD(for: ball, cameraAt: firstSwitch * 0.9, camera: camera) + XCTAssertEqual(component.currentLOD, 0) + selectLOD(for: ball, cameraAt: firstSwitch * 1.2, camera: camera) + XCTAssertEqual(component.currentLOD, 1) + } + + func testAViewWithoutPerspectiveSwitchesAtTheDistancesOfTheLoad() async throws { + let packURL = try writePack([Placement(name: "Ball", asset: "ball", path: "Ball/Ball.untold")]) + let (_, children) = try await loadPack(packURL) + let ball = try XCTUnwrap(children["Ball"]) + let component = try lod(ball) + let camera = makeCamera() + let firstSwitch = component.lodLevels[0].maxDistance + let savedProjection = renderInfo.perspectiveSpace + defer { renderInfo.perspectiveSpace = savedProjection } + + // Scaled, the ball would keep its model three times as far by its size on screen. + scaleTo(entityId: ball, scale: simd_float3(repeating: 3)) + renderInfo.perspectiveSpace = simd_float4x4(diagonal: simd_float4(0.1, 0.1, 0.01, 1)) + + selectLOD(for: ball, cameraAt: firstSwitch * 0.9, camera: camera) + XCTAssertEqual(component.currentLOD, 0) + selectLOD(for: ball, cameraAt: firstSwitch * 1.2, camera: camera) + XCTAssertEqual(component.currentLOD, 1) + } + func testAMaterialEditStaysWithThePlacementThroughItsLevels() async throws { let packURL = try writePack([ Placement(name: "Ball A", asset: "ball", path: "Ball/Ball.untold"), diff --git a/Tests/UntoldEngineTests/LODScreenSizeTests.swift b/Tests/UntoldEngineTests/LODScreenSizeTests.swift new file mode 100644 index 000000000..afbc75233 --- /dev/null +++ b/Tests/UntoldEngineTests/LODScreenSizeTests.swift @@ -0,0 +1,201 @@ +// +// LODScreenSizeTests.swift +// UntoldEngine +// +// Choosing a LOD level by the size of the entity on screen. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +@MainActor +final class LODScreenSizeTests: XCTestCase { + override func setUp() async throws { + resetEngineTestState() + } + + override func tearDown() async throws { + destroyAllEntities() + } + + /// A 90° field of view: half the height of the view is as long as the distance. + private func perspective(fovYDegrees: Float = 90) -> simd_float4x4 { + matrixPerspectiveRightHandReverseZ(fovyRadians: degreesToRadians(degrees: fovYDegrees), aspectRatio: 16.0 / 9.0, nearZ: 0.1, farZ: 1000) + } + + /// Four levels that take over at a half, a quarter and a tenth of the viewport + /// height. Their distances say something else on purpose. + private var levels: [LODLevel] { + [ + LODLevel(mesh: [], maxDistance: 1000), + LODLevel(mesh: [], maxDistance: 2000, screenPercentage: 0.5), + LODLevel(mesh: [], maxDistance: 3000, screenPercentage: 0.25), + LODLevel(mesh: [], maxDistance: .greatestFiniteMagnitude, screenPercentage: 0.1), + ] + } + + private func selected( + distance: Float, + reach: Float, + current: Int = 0, + forced: Int? = nil, + bias: Float = 1, + hysteresis: Float = 0, + levels: [LODLevel]? = nil, + globalDistances: [Float] = [] + ) -> Int { + selectLODIndex( + levels: levels ?? self.levels, + distance: distance, + reach: reach, + currentLOD: current, + forcedLOD: forced, + lodBias: bias, + hysteresis: hysteresis, + globalDistances: globalDistances + ) + } + + // MARK: - What a screen size is worth in distance + + func testAUnitSphereFillsTheViewportAtTheReach() { + // Radius 1 under 90°: the sphere is as tall as the view at distance 1. + XCTAssertEqual(try XCTUnwrap(lodScreenSizeReach(projection: perspective())), 1, accuracy: 1e-5) + } + + func testANarrowerFieldOfViewReachesFarther() throws { + let wide = try XCTUnwrap(lodScreenSizeReach(projection: perspective(fovYDegrees: 90))) + let narrow = try XCTUnwrap(lodScreenSizeReach(projection: perspective(fovYDegrees: 45))) + + XCTAssertEqual(narrow / wide, 1 / tan(degreesToRadians(degrees: 22.5)), accuracy: 1e-4) + } + + func testTheShapeOfTheViewportDoesNotChangeTheReach() throws { + let wide = matrixPerspectiveRightHandReverseZ(fovyRadians: degreesToRadians(degrees: 90), aspectRatio: 3, nearZ: 0.1, farZ: 1000) + let tall = matrixPerspectiveRightHandReverseZ(fovyRadians: degreesToRadians(degrees: 90), aspectRatio: 0.5, nearZ: 0.1, farZ: 1000) + + XCTAssertEqual(try XCTUnwrap(lodScreenSizeReach(projection: wide)), 1, accuracy: 1e-5) + XCTAssertEqual(try XCTUnwrap(lodScreenSizeReach(projection: tall)), 1, accuracy: 1e-5) + } + + func testAViewWithoutPerspectiveHasNoReach() { + let orthographic = simd_float4x4(diagonal: simd_float4(0.1, 0.1, 0.01, 1)) + + XCTAssertNil(lodScreenSizeReach(projection: orthographic)) + XCTAssertNil(lodScreenSizeReach(projection: matrix_identity_float4x4)) + } + + // MARK: - Selection + + func testAStereoFrameTakesTheLargerOfTheEyesReaches() throws { + // The eye that sees an entity largest decides its level. + let wide = perspective(fovYDegrees: 90), narrow = perspective(fovYDegrees: 60) + let reach = try XCTUnwrap(lodScreenSizeReach(eyeProjections: [wide, narrow])) + XCTAssertEqual(reach, try XCTUnwrap(lodScreenSizeReach(projection: narrow)), accuracy: 1e-6) + XCTAssertEqual(reach, try XCTUnwrap(lodScreenSizeReach(eyeProjections: [narrow, wide])), accuracy: 1e-6, "whichever eye comes first") + XCTAssertEqual(try XCTUnwrap(lodScreenSizeReach(eyeProjections: [wide, matrix_identity_float4x4])), try XCTUnwrap(lodScreenSizeReach(projection: wide)), accuracy: 1e-6, "an eye not yet rendered does not count") + } + + func testBeforeAnyEyeIsRenderedAStereoFrameHasNoReach() { + // Until `renderXR` has run, the eye projections are the identity: the stored + // distances serve, not a reach from the window's projection. + XCTAssertNil(lodScreenSizeReach(eyeProjections: [matrix_identity_float4x4, matrix_identity_float4x4])) + XCTAssertNil(lodScreenSizeReach(eyeProjections: [])) + } + + func testALevelTakesOverWhereTheEntityCoversItsScreenSize() { + // With a reach of 10 the entity covers 0.5 at 20, 0.25 at 40 and 0.1 at 100. + XCTAssertEqual(selected(distance: 19, reach: 10), 0) + XCTAssertEqual(selected(distance: 21, reach: 10), 1) + XCTAssertEqual(selected(distance: 39, reach: 10), 1) + XCTAssertEqual(selected(distance: 41, reach: 10), 2) + XCTAssertEqual(selected(distance: 99, reach: 10), 2) + XCTAssertEqual(selected(distance: 101, reach: 10), 3) + XCTAssertEqual(selected(distance: 1_000_000, reach: 10), 3, "the last level has no end") + } + + func testALargerEntityOrALargerViewKeepsTheFinerLevelForLonger() { + // Three times the radius, or a field of view to that effect. + XCTAssertEqual(selected(distance: 59, reach: 30), 0) + XCTAssertEqual(selected(distance: 61, reach: 30), 1) + XCTAssertEqual(selected(distance: 301, reach: 30), 3) + } + + func testTheBiasMovesEverySwitchTogether() { + XCTAssertEqual(selected(distance: 11, reach: 10, bias: 2), 1, "twice as early") + XCTAssertEqual(selected(distance: 30, reach: 10, bias: 0.5), 0, "half as early") + } + + func testTheHysteresisHoldsTheCoarserLevelJustInsideTheSwitch() { + // The switch is at 20. Coming back, a hysteresis of 1 puts it at 19. + XCTAssertEqual(selected(distance: 19.5, reach: 10, current: 1, hysteresis: 1), 1) + XCTAssertEqual(selected(distance: 18.5, reach: 10, current: 1, hysteresis: 1), 0) + // It never holds more than a tenth of the switch distance. + XCTAssertEqual(selected(distance: 17.9, reach: 10, current: 1, hysteresis: 50), 0) + XCTAssertEqual(selected(distance: 18.1, reach: 10, current: 1, hysteresis: 50), 1) + } + + func testAForcedLevelWins() { + XCTAssertEqual(selected(distance: 5, reach: 10, forced: 2), 2) + XCTAssertEqual(selected(distance: 5, reach: 10, forced: 9), 3) + } + + func testALevelWithoutAScreenSizeTakesOverAtTheDistanceOfTheOneBefore() { + var levels = levels + levels[2].screenPercentage = 0 + levels[1].maxDistance = 70 + + XCTAssertEqual(selected(distance: 21, reach: 10, levels: levels), 1) + XCTAssertEqual(selected(distance: 69, reach: 10, levels: levels), 1, "level 1 ends at its own distance") + XCTAssertEqual(selected(distance: 71, reach: 10, levels: levels), 2) + XCTAssertEqual(selected(distance: 101, reach: 10, levels: levels), 3) + } + + func testAnEntityWithoutASizeFallsBackToTheDistances() { + XCTAssertEqual(selected(distance: 999, reach: 0), 0) + XCTAssertEqual(selected(distance: 1001, reach: 0), 1) + XCTAssertEqual(selected(distance: 2500, reach: .nan), 2) + } + + // MARK: - The sphere an entity is measured by + + func testTheRadiusIsTheSphereAroundTheBoundsUnderTheWorldScale() throws { + let entity = createEntity() + registerTransformComponent(entityId: entity) + let local = try XCTUnwrap(scene.get(component: LocalTransformComponent.self, for: entity)) + local.boundingBox = (min: simd_float3(-1, -2, -2), max: simd_float3(1, 2, 2)) + translateTo(entityId: entity, position: simd_float3(0, 0, -50)) + scaleTo(entityId: entity, scale: simd_float3(repeating: 3)) + traverseSceneGraph() + + let measured = entityDistanceAndRadius(entityId: entity, cameraPosition: simd_float3(0, 0, 10), localRadius: 0) + + XCTAssertEqual(measured.distance, 60, accuracy: 1e-3) + XCTAssertEqual(measured.radius, 9, accuracy: 1e-3, "half the diagonal of 2 x 4 x 4, three times") + } + + func testAPartOfAModelIsMeasuredByTheRadiusItCarries() { + let entity = createEntity() + registerTransformComponent(entityId: entity) + scaleTo(entityId: entity, scale: simd_float3(2, 2, 2)) + traverseSceneGraph() + + let measured = entityDistanceAndRadius(entityId: entity, cameraPosition: simd_float3(0, 0, 10), localRadius: 25) + + XCTAssertEqual(measured.radius, 50, accuracy: 1e-3) + } + + func testTheComponentSelectsByDistanceUnlessTold() { + let component = LODComponent() + + XCTAssertFalse(component.selectsByScreenSize) + XCTAssertEqual(component.screenSizeRadius, 0) + } +} diff --git a/docs/API/UsingLODSystem.md b/docs/API/UsingLODSystem.md index c346fecb8..5370214e8 100644 --- a/docs/API/UsingLODSystem.md +++ b/docs/API/UsingLODSystem.md @@ -227,20 +227,24 @@ chain: - **The levels are built once.** Placements of the same model share the GPU buffers of every level, as they share those of the model. -- **Each placement switches at its own distances.** The manifest gives every +- **Each placement switches by its size on screen.** The manifest gives every level a screen size: the share of the viewport height the model's bounding - sphere covers when the level becomes detailed enough. On load that size becomes - a distance for the placement, from the model's bounds, the placement's scale and - the engine's field of view. A small prop and a large tree therefore both switch - where their triangles are a few pixels each, although their distances differ - by orders of magnitude. The distances are computed when the pack loads; scaling - a placement afterwards does not move them. + sphere covers when the level becomes detailed enough. The engine compares it + with the size the placement has in the frame being drawn, from the model's + bounds, the placement's scale and the field of view (see [Selecting by Screen + Size](#selecting-by-screen-size)). A small prop and a large tree therefore + both switch where their triangles are a few pixels each, although their + distances differ by orders of magnitude, and they keep doing so when a + placement is scaled or the camera zooms. The parts of a model that has + several change level together, at the size of the whole model. - **The materials belong to the entity.** The levels are drawn with the model's materials, and a material that is edited or streamed while one level is on screen stays when another takes its place (`LODComponent.levelsShareMaterials`). -`setLOD(.distanceBias(...))` moves all switch distances together, and -`forcedLOD` pins a level, as for any other LOD entity. +`setLOD(.distanceBias(...))` moves all switches together, and `forcedLOD` pins +a level, as for any other LOD entity. Each level also carries, in +`maxDistance`, the distance its switch falls at as the pack loads; it is what +an orthographic view uses. The manifest records the chains under `lodChains`, by model path: @@ -254,8 +258,9 @@ The manifest records the chains under `lodChains`, by model path: } ``` -`screenSize` is measured on a viewport 1080 pixels high, and `error` is the -largest deviation from the model in model units. +`screenSize` is a share of the viewport height: the cook chooses it so that the +level's triangles are about four pixels each on a viewport 1080 pixels high. +`error` is the largest deviation from the model in model units. To build chains from your own tools, add the `UntoldEngineMeshCook` product of the engine package to the tool and call the cooker: @@ -325,6 +330,40 @@ setLOD(.distanceThresholds([25.0, 75.0, 200.0])) The older `LODConfig.shared` values remain available for compatibility and advanced tuning. New code should prefer `setLOD(...)` so LOD settings follow the same style as scene channels, rendering, and PostFX. +### Selecting by Screen Size + +A distance suits one size of object under one field of view. An entity can +instead choose its level by how large it is on screen: + +```swift +if let lodComponent = scene.get(component: LODComponent.self, for: tree) { + lodComponent.lodLevels[1].screenPercentage = 0.5 // LOD1 from half the viewport height down + lodComponent.lodLevels[2].screenPercentage = 0.2 // LOD2 from a fifth down + lodComponent.selectsByScreenSize = true +} +``` + +`screenPercentage` is the size at which a level takes over from the one before +it: the share of the viewport height (1 is the whole height) covered by the +sphere around the entity's bounding box. The size is read as each LOD pass +runs, so it follows the entity's scale and the field of view: + +- The size is a share of the viewport, whatever its resolution: a denser + display draws the same levels as a coarser one. `setLOD(.distanceBias(...))` + moves every switch where a platform needs more or less detail, and + `setLOD(.hysteresis(...))` acts as it does on distances. +- A level whose `screenPercentage` is 0 takes over at the `maxDistance` of the + level before it, and so does every level under an orthographic projection. +- In stereo the size is measured with the eye that sees the entity largest, + from the frame rendered last; until the first frame has been rendered the + `maxDistance` values apply. +- `LODComponent.screenSizeRadius` replaces the sphere around the bounding box + with one of that radius, in the entity's own space. The parts of a model + loaded from a pack carry the radius of the whole model. + +The levels of [automatic LOD chains](#automatic-lod-chains-for-packs) are +selected this way. + ### Forced LOD Override Force a specific LOD level (useful for debugging): diff --git a/docs/Architecture/lodSystem.md b/docs/Architecture/lodSystem.md index 23c78e367..6169a054a 100644 --- a/docs/Architecture/lodSystem.md +++ b/docs/Architecture/lodSystem.md @@ -6,7 +6,7 @@ UntoldEngine has two separate LOD mechanisms that operate at different granulari |---|---|---| | Unit | Individual mesh entity (`LODComponent`) | Whole tile `.untold` file (`TileLODLevel`) | | Control | `LODSystem` — called every frame, but the full entity pass is throttled to every `lodUpdateFrameInterval` frames (default 4) unless the camera moved past `minimumCameraDisplacementForLODUpdate` (default 0.5 units) | `GeometryStreamingSystem.update()` — runs per tick | -| Switch trigger | Camera distance vs `LODLevel.maxDistance` | Camera distance vs `TileLODLevel.switchDistance` (with hysteresis) | +| Switch trigger | Camera distance vs `LODLevel.maxDistance`, or size on screen vs `LODLevel.screenPercentage` for a component that sets `selectsByScreenSize` | Camera distance vs `TileLODLevel.switchDistance` (with hysteresis) | | Hysteresis | 5-unit inner band on finer-LOD transitions only, at most a tenth of the level's switch distance | `lodHysteresisFactor` (default 0.90 = 10% band) on active level | | Meshes in memory | All LOD levels GPU-resident simultaneously | Only the active LOD level is loaded | | Use case | Individual detailed objects (buildings, props) | Tile-granularity intermediate representations for large scenes | @@ -82,6 +82,16 @@ Beyond all thresholds → desiredLOD = 2 (lowest available) **Hysteresis:** When switching to a *finer* LOD (e.g. camera approaching, LOD2→LOD1), the threshold is tightened by `5.0` units, and never by more than a tenth of the threshold itself (`lodHysteresisDistanceShare`): a level that switches a few units from the camera would otherwise never be returned to. This prevents flickering when the camera hovers right at a boundary. Switching to *coarser* LODs has no penalty — it happens immediately. +**By screen size:** a component with `selectsByScreenSize` set (the levels of a pack's automatic LOD chain are) does not compare against the stored distances. Each level ends at the distance where the entity covers the `screenPercentage` of the next one: + +``` +worldRadius = radius of the sphere around the local AABB (or screenSizeRadius) × largest world scale +reach = projection[1][1] (once per pass, lodScreenSizeReach) +switchDistance = worldRadius × reach / nextLevel.screenPercentage +``` + +`projection[1][1]` is `1 / tan(fovY / 2)` of the projection in use (`renderInfo.perspectiveSpace`), so the switch follows the entity's scale and the field of view. A screen size is a share of the viewport height whatever its resolution, so a denser display draws the same levels. The walk, the bias and the hysteresis are those above, applied to these distances. Under an orthographic projection, and for a level whose successor has no screen size, the stored `maxDistance` applies. In stereo (`renderInfo.isXRStereoMode`) the eyes are rendered after the update, so the reach is the larger of the two eyes' reaches from the frame rendered last (`xrEye0Projection`, `xrEye1Projection`, `lodScreenSizeReach(eyeProjections:)`); a headset's field of view does not change between frames, and until an eye has been rendered the projections are the identity and the stored distances apply. + --- #### 3c. `resolveActualLOD()` From e544326a3cd226e06b8cf57dff91c1c1be909a19 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Thu, 8 Oct 2026 23:08:51 -0700 Subject: [PATCH 06/11] [Docs] Add a getting-started tutorial for Jolt Physics in XR (#1327) Covers dependency setup, registering the backend before the renderer, collider sizing (including the mesh-scale-vs-collider-radius pitfall), automatic stepping, resetting a body via the backend's resetBody side channel, and the current damping limitation, cross-checked against the CoolBasket and ImmersiveGol demos. --- docs/API/UsingJoltPhysicsXR.md | 170 +++++++++++++++++++++++++++++++++ docs/index.md | 1 + mkdocs.yml | 1 + 3 files changed, 172 insertions(+) create mode 100644 docs/API/UsingJoltPhysicsXR.md diff --git a/docs/API/UsingJoltPhysicsXR.md b/docs/API/UsingJoltPhysicsXR.md new file mode 100644 index 000000000..bb982ac56 --- /dev/null +++ b/docs/API/UsingJoltPhysicsXR.md @@ -0,0 +1,170 @@ +# Getting Started: Jolt Physics in an XR Project + +[UntoldJoltPhysics](https://github.com/untoldengine/UntoldJoltPhysics) installs +[Jolt Physics](https://github.com/jrouwe/JoltPhysics) behind the engine's +pluggable [Physics Backend](../Extensions/CreatingAPhysicsBackendPlugin.md) +seam. Rigid bodies described with the engine-owned `RigidBodyComponent` / +`ColliderComponent` are simulated by Jolt instead of the built-in kinetics +integrator — this gives you real collision detection, contacts, triggers and +a character controller, which matters most for XR scenes where hands and +real-world surfaces need to collide with gameplay objects. + +This is a getting-started tutorial, not a full reference. See +[Physics System](UsingPhysicsSystem.md) for the engine-owned component +vocabulary and the [UntoldJoltPhysics README](https://github.com/untoldengine/UntoldJoltPhysics) +for the complete backend reference. + +## 1. Add the Dependency + +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/untoldengine/UntoldEngine.git", branch: "develop"), + .package(url: "https://github.com/untoldengine/UntoldJoltPhysics.git", branch: "develop"), +], +targets: [ + .target( + name: "YourApp", + dependencies: [ + .product(name: "UntoldEngine", package: "UntoldEngine"), + .product(name: "UntoldJoltPhysics", package: "UntoldJoltPhysics"), + ] + ), +] +``` + +Jolt is compiled from source (no binaries) and supports macOS 14+, iOS 17+ +and visionOS 2+, device and simulator. + +## 2. Register the Backend Before the Renderer + +```swift +import UntoldJoltPhysics + +// Must run before the renderer is created — the registry locks on the +// first simulated substep, so a backend can't be swapped in mid-run. +let jolt = registerJoltPhysics() +``` + +If an immersive space is closed and reopened, the registry is already +locked from the previous run. Check for an existing backend first instead of +registering again: + +```swift +if let active = PhysicsBackendRegistry.shared.activeBackend() { + // Reuse it. +} else { + registerJoltPhysics() +} +``` + +## 3. Give Entities Physics Components + +Jolt doesn't introduce new component types — it simulates the engine's own +`RigidBodyComponent` and `ColliderComponent`: + +```swift +let ball = createEntity() +setEntityMeshAsync(entityId: ball, filename: "ball", withExtension: "untold") + +registerComponent(entityId: ball, componentType: ColliderComponent.self) +registerComponent(entityId: ball, componentType: RigidBodyComponent.self) + +if let collider = scene.get(component: ColliderComponent.self, for: ball) { + collider.shape = .sphere(radius: 0.12) + collider.restitution = 0.78 +} +if let body = scene.get(component: RigidBodyComponent.self, for: ball) { + body.motionType = .dynamic + body.mass = 0.62 +} +``` + +Size colliders to match the mesh's real-world scale — a size-7 basketball is +about 0.12 m in radius. When a collider sits on a visible surface (a rim, a +rope), give it a slightly larger radius than the visual geometry so contacts +read as solid instead of grazing. + +Watch for a mismatch between the *collider's* radius and the *mesh's* +authored scale — a soccer ball model exported at 1 m diameter still needs its +node scaled down to match an 0.11 m-radius collider, or the visual and the +physics will disagree about where the ball's surface is: + +```swift +scaleTo(entityId: ball, scale: SIMD3(repeating: ballRadius * 2)) +``` + +## 4. XR Hands as Kinematic Bodies + +Represent tracked hands as kinematic spheres so dynamic bodies collide +against them, and park them far away when tracking is lost so they don't +sweep through the scene on reacquisition: + +```swift +let hand = createEntity() +registerComponent(entityId: hand, componentType: ColliderComponent.self) +registerComponent(entityId: hand, componentType: RigidBodyComponent.self) + +if let collider = scene.get(component: ColliderComponent.self, for: hand) { + collider.shape = .sphere(radius: 0.07) +} +if let body = scene.get(component: RigidBodyComponent.self, for: hand) { + body.motionType = .kinematic +} + +// Each frame: +translateTo(entityId: hand, position: trackedPosition ?? SIMD3(0, -100, 0)) +``` + +`JoltWorldSettings.maxKinematicStep` / `maxKinematicSpeed` guard against a +hand that reappears far from where it was — without them, a tracking glitch +can look like a swat at full speed. + +## 5. Stepping Happens Automatically + +Once registered, Jolt steps every fixed update as part of the engine's +physics loop — there's no `step()` call to make yourself. +`JoltWorldSettings.collisionSteps` controls how many Jolt sub-steps run per +engine step; the default of 1 is fine for most scenes, and a higher value +helps fast-moving bodies or cloth stay stable. + +## 6. Resetting a Body + +Removing and re-adding `RigidBodyComponent`/`ColliderComponent` within the +same frame is invisible to the coordinator's per-substep diff, so it isn't a +reliable way to reset a body's pose. Use the backend's own reset instead: + +```swift +if let jolt = PhysicsBackendRegistry.shared.activeBackend() as? JoltPhysicsBackend { + jolt.resetBody(entity: ball, position: spawnPoint, velocity: .zero) +} +``` + +This is the right tool for teleporting an entity back into play — e.g. a +ball that fell out of bounds. + +## 7. A Note on Damping + +`RigidBodyComponent` has no damping field, and the Jolt backend does not +currently wire per-body linear/angular damping through for rigid bodies +(only soft bodies expose it). A Jolt-backed rigid body uses Jolt's own +default linear damping (0.05 in Jolt 5.6) with no way to configure or query +it through the engine seam yet — if you need exact ballistic trajectories, +you have to account for that decay yourself. If you need the kind of +damping control described in +[Physics System](UsingPhysicsSystem.md#damping-and-speed-limits) +(`applyLinearDamping`, `applyAngularDamping`), note that those helpers act on +the built-in kinetics integrator and have no effect on entities simulated by +Jolt. + +## Next Steps + +For a complete, working visionOS example — a basketball demo with hand +tracking, a trigger-based scoring volume, real-world surface detection, and +backend switching at launch — see +[CoolBasket](https://github.com/untoldengine/UntoldArcade), which runs on +either its own built-in backend or UntoldJoltPhysics interchangeably. + +For everything else the backend supports — layers, contact/trigger events, +raycasts, the character controller and soft bodies — see the +[UntoldJoltPhysics README](https://github.com/untoldengine/UntoldJoltPhysics). diff --git a/docs/index.md b/docs/index.md index d5966b7fd..06ac51b6d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -190,6 +190,7 @@ See [COMMERCIAL.md](https://github.com/untoldengine/UntoldEngine/blob/main/COMME - [Lighting System](API/UsingLightingSystem.md) - [Animation System](API/UsingAnimationSystem.md) - [Physics System](API/UsingPhysicsSystem.md) +- [Jolt Physics in XR](API/UsingJoltPhysicsXR.md) - [Steering System](API/UsingSteeringSystem.md) - [Post FX](API/UsingPostFX.md) - [LOD System](API/UsingLODSystem.md) diff --git a/mkdocs.yml b/mkdocs.yml index d2ed3de16..315bb494e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,6 +85,7 @@ nav: - Root Motion: API/UsingRootMotion.md - Physics Pose: API/UsingPhysicsPose.md - Physics System: API/UsingPhysicsSystem.md + - Jolt Physics in XR: API/UsingJoltPhysicsXR.md - Steering System: API/UsingSteeringSystem.md - Scripting System: API/UsingScriptingSystem.md - Post Effects: API/UsingPostFX.md From 23d66a7360f9367982d8b43570d46fba4a41c34b Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Fri, 9 Oct 2026 06:34:38 -0700 Subject: [PATCH 07/11] [Bugfix] Detect pip --break-system-packages support in make testrenderer (#1328) Xcode's bundled Python ships pip 21.2.4, which rejects --break-system-packages (added in pip 23.0.1) and aborts the target before it can even upgrade pip. Detect flag support via pip --help instead of hardcoding it, and honor UNTOLD_PYTHON so the installs target the same interpreter the tests use. Fixes #1325 Co-authored-by: Claude Sonnet 5 --- Makefile | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index e2673358e..e14cec16f 100644 --- a/Makefile +++ b/Makefile @@ -43,9 +43,20 @@ testcore: # machine has less RAM/GPU headroom. WORKERS ?= 4 +# Honor UNTOLD_PYTHON so the interpreter we install into matches the one +# BaseRenderSetup actually shells out to at test time (default: python3). +TESTRENDERER_PYTHON ?= $(if $(UNTOLD_PYTHON),$(UNTOLD_PYTHON),python3) + +# --break-system-packages only exists in pip 23.0.1+. Xcode's bundled Python +# (pip 21.2.4 as of this writing) rejects it as an unknown option, which aborts +# the target before it can even upgrade pip. Detect support instead of +# hardcoding the flag so Homebrew/python.org pips (which need it under PEP 668) +# keep working while older pips fall back to an unflagged install. +PIP_BREAK_FLAG := $(shell $(TESTRENDERER_PYTHON) -m pip install --help 2>/dev/null | grep -q -- --break-system-packages && echo --break-system-packages) + testrenderer: - python3 -m pip install --user --break-system-packages --upgrade pip wheel setuptools - python3 -m pip install --user --break-system-packages opencv-python-headless scikit-image + $(TESTRENDERER_PYTHON) -m pip install --user $(PIP_BREAK_FLAG) --upgrade pip wheel setuptools + $(TESTRENDERER_PYTHON) -m pip install --user $(PIP_BREAK_FLAG) opencv-python-headless scikit-image UNTOLD_KEEP_ARTIFACTS=$(KEEP) swift test --parallel --num-workers $(WORKERS) --filter UntoldEngineRenderTests # AsyncMeshLoadingTest/AssetLoadingGateRenderingTests only — the two classes CI runs From 0b2863d288e841afb4c51e30a6a25e4e3148a3e8 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sat, 10 Oct 2026 02:02:11 +0200 Subject: [PATCH 08/11] [Bugfix] Shade each XR eye from its own position (#1285) * [Bugfix] Shade each XR eye from its own position In XR the lighting, model, batch, transparency, sky and render-extension passes took their view vector from CameraComponent.localPosition, which UntoldEngineXR sets to the head centre once a frame for streaming (setXRCameraWorldPosition). Both eyes were shaded as if seen from between them, so specular highlights and Fresnel had no stereo disparity and read as painted on the surface up close. renderXR now stores the position of the eye it draws, taken from that eye's view matrix, in renderInfo.xrEyeCameraPosition, and the shading passes read shadingCameraPosition(_:), which returns it (through the scene root, like before) or the camera's position outside XR; the non-XR draw clears it. Streaming, LOD, octree and culling keep the head centre. Gaussian splats keep it too: their colour is evaluated once per frame for both eyes in the shared preprocess. * [Patch] Write the xrEyeCameraPosition comment as a doc comment SwiftFormat 0.60.1 (the version CI pins) wants a doc comment on a lone property declaration; the lint job failed on it. --------- Co-authored-by: Javier Segura --- .../RenderExtensionModelSurface.swift | 2 +- .../UntoldEngine/Renderer/RenderPasses.swift | 34 ++++---- .../Renderer/RenderResources.swift | 20 +++++ .../UntoldEngine/Renderer/UntoldEngine.swift | 6 ++ .../ShadingCameraPositionTests.swift | 82 +++++++++++++++++++ 5 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 Tests/UntoldEngineTests/ShadingCameraPositionTests.swift diff --git a/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift index 119474c6c..83f2deaba 100644 --- a/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift +++ b/Sources/UntoldEngine/Renderer/RenderExtensionModelSurface.swift @@ -479,7 +479,7 @@ private func drawModelSurfaceMeshes( uniforms.normalMatrix = normalMatrix uniforms.viewMatrix = viewMatrix uniforms.modelMatrix = modelMatrix - uniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + uniforms.cameraPosition = shadingCameraPosition(cameraComponent) uniforms.projectionMatrix = renderInfo.perspectiveSpace renderEncoder.setVertexBytes( diff --git a/Sources/UntoldEngine/Renderer/RenderPasses.swift b/Sources/UntoldEngine/Renderer/RenderPasses.swift index b522ae85c..8256831bf 100644 --- a/Sources/UntoldEngine/Renderer/RenderPasses.swift +++ b/Sources/UntoldEngine/Renderer/RenderPasses.swift @@ -291,7 +291,7 @@ public enum RenderPasses { init(cameraComponent: CameraComponent) { viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + cameraPosition = shadingCameraPosition(cameraComponent) } /// The entity's LOD component while LOD fades are on; nil otherwise, and for an @@ -1119,7 +1119,7 @@ public enum RenderPasses { let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) skyUniforms.invViewMatrix = viewMatrix.inverse skyUniforms.invProjectionMatrix = renderInfo.perspectiveSpace.inverse - skyUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + skyUniforms.cameraPosition = shadingCameraPosition(cameraComponent) let sunParameters = getDirectionalLightParameters() skyUniforms.sunDirection = sunParameters.direction @@ -1320,7 +1320,7 @@ public enum RenderPasses { let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) // What every draw of the pass shares, read once. let sceneSnapshot = RenderSceneSnapshot() - let cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let cameraPosition = shadingCameraPosition(cameraComponent) let projectionMatrix = renderInfo.perspectiveSpace // Render each cascade into its own depth array slice. @@ -1433,7 +1433,7 @@ public enum RenderPasses { batchUniforms.viewMatrix = viewMatrix batchUniforms.modelViewMatrix = viewMatrix batchUniforms.normalMatrix = matrix_identity_float3x3 - batchUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + batchUniforms.cameraPosition = shadingCameraPosition(cameraComponent) batchUniforms.projectionMatrix = renderInfo.perspectiveSpace for cascadeIdx in 0 ..< csmCascadeCount { @@ -1558,7 +1558,7 @@ public enum RenderPasses { let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) // What every draw of the pass shares, read once. let sceneSnapshot = RenderSceneSnapshot() - let cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let cameraPosition = shadingCameraPosition(cameraComponent) let projectionMatrix = renderInfo.perspectiveSpace for caster in spotShadowCasters(in: sceneSnapshot) { let transformComponent = caster.components.world @@ -1598,7 +1598,7 @@ public enum RenderPasses { batchUniforms.viewMatrix = viewMatrix batchUniforms.modelViewMatrix = viewMatrix batchUniforms.normalMatrix = matrix_identity_float3x3 - batchUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + batchUniforms.cameraPosition = shadingCameraPosition(cameraComponent) batchUniforms.projectionMatrix = renderInfo.perspectiveSpace for batchGroup in spotShadowCasterBatchGroups() { @@ -1658,7 +1658,7 @@ public enum RenderPasses { let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) // What every draw of the pass shares, read once. let sceneSnapshot = RenderSceneSnapshot() - let cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let cameraPosition = shadingCameraPosition(cameraComponent) let projectionMatrix = renderInfo.perspectiveSpace let casters = pointShadowCasters(in: sceneSnapshot) let batchGroups = BatchingSystem.shared.isEnabled() ? pointShadowCasterBatchGroups() : [] @@ -1732,7 +1732,7 @@ public enum RenderPasses { batchUniforms.viewMatrix = viewMatrix batchUniforms.modelViewMatrix = viewMatrix batchUniforms.normalMatrix = matrix_identity_float3x3 - batchUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + batchUniforms.cameraPosition = shadingCameraPosition(cameraComponent) batchUniforms.projectionMatrix = renderInfo.perspectiveSpace for batchGroup in batchGroups { @@ -2130,7 +2130,7 @@ public enum RenderPasses { batchUniforms.viewMatrix = viewMatrix batchUniforms.modelViewMatrix = modelViewMatrix batchUniforms.normalMatrix = normalMatrix - batchUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + batchUniforms.cameraPosition = shadingCameraPosition(cameraComponent) batchUniforms.projectionMatrix = renderInfo.perspectiveSpace // Render only batch groups that contain at least one visible entity. @@ -2473,7 +2473,7 @@ public enum RenderPasses { batchUniforms.viewMatrix = viewMatrix batchUniforms.modelViewMatrix = simd_mul(viewMatrix, modelMatrix) batchUniforms.normalMatrix = matrix3x3_upper_left(modelMatrix).inverse.transpose - batchUniforms.cameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + batchUniforms.cameraPosition = shadingCameraPosition(cameraComponent) batchUniforms.projectionMatrix = renderInfo.perspectiveSpace for batchGroup in visibleBatchGroups { @@ -2618,7 +2618,7 @@ public enum RenderPasses { lightQuadEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) lightQuadEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) - var effectiveCamPos = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + var effectiveCamPos = shadingCameraPosition(cameraComponent) lightQuadEncoder.setFragmentBytes(&effectiveCamPos, length: MemoryLayout.stride, index: Int(lightPassCameraPositionIndex.rawValue)) var csmUniforms = shadowSystem.makeUniforms() @@ -3279,7 +3279,7 @@ public enum RenderPasses { renderEncoder.setVertexBuffer(bufferResources.quadVerticesBuffer, offset: 0, index: 0) renderEncoder.setVertexBuffer(bufferResources.quadTexCoordsBuffer, offset: 0, index: 1) - var effectiveCamPos = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + var effectiveCamPos = shadingCameraPosition(cameraComponent) renderEncoder.setFragmentBytes(&effectiveCamPos, length: MemoryLayout.stride, index: Int(lightPassCameraPositionIndex.rawValue)) // CSM uniforms: pack all cascade matrices + split distances into one struct. @@ -3638,7 +3638,7 @@ public enum RenderPasses { index: Int(transparencyPassLightOrthoViewMatrixIndex.rawValue) ) - var effectiveCamPos = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + var effectiveCamPos = shadingCameraPosition(cameraComponent) renderEncoder.setFragmentBytes( &effectiveCamPos, length: MemoryLayout.stride, @@ -3725,7 +3725,7 @@ public enum RenderPasses { index: Int(transparencyPassIBLRotationAngleIndex.rawValue) ) - let effectiveCameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let effectiveCameraPosition = shadingCameraPosition(cameraComponent) // What every draw of the pass shares, read once. let sceneSnapshot = RenderSceneSnapshot() @@ -3996,7 +3996,7 @@ public enum RenderPasses { guard !shellEntityIds.isEmpty else { return } let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - let effectiveCameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let effectiveCameraPosition = shadingCameraPosition(cameraComponent) let descriptor = MTLRenderPassDescriptor() descriptor.renderTargetWidth = depthTexture.width @@ -4089,7 +4089,7 @@ public enum RenderPasses { return } let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - let effectiveCameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let effectiveCameraPosition = shadingCameraPosition(cameraComponent) wireframeRenderStateLock.lock() let wireframeSettings = wireframeRenderState wireframeRenderStateLock.unlock() @@ -4278,7 +4278,7 @@ public enum RenderPasses { return } let viewMatrix = SceneRootTransform.shared.effectiveViewMatrix(cameraComponent.viewSpace) - let effectiveCameraPosition = SceneRootTransform.shared.effectiveCameraPosition(cameraComponent.localPosition) + let effectiveCameraPosition = shadingCameraPosition(cameraComponent) let sceneSnapshot = RenderSceneSnapshot() let batchingEnabled = BatchingSystem.shared.isEnabled() diff --git a/Sources/UntoldEngine/Renderer/RenderResources.swift b/Sources/UntoldEngine/Renderer/RenderResources.swift index 51b8dbd44..d30c80743 100644 --- a/Sources/UntoldEngine/Renderer/RenderResources.swift +++ b/Sources/UntoldEngine/Renderer/RenderResources.swift @@ -82,6 +82,26 @@ public struct RenderInfo { public var xrEye0Projection: simd_float4x4 = matrix_identity_float4x4 public var xrEye1View: simd_float4x4 = matrix_identity_float4x4 public var xrEye1Projection: simd_float4x4 = matrix_identity_float4x4 + /// The position of the eye `renderXR` is drawing, in the same space as + /// CameraComponent.localPosition; nil outside XR. Shading reads it through + /// shadingCameraPosition so each eye gets its own view vector (specular highlights, + /// Fresnel), while streaming, LOD and culling keep the head-centre camera position. + public var xrEyeCameraPosition: simd_float3? +} + +/// Where an eye is, from its view matrix (the inverse of the eye's camera transform). +@inline(__always) +func eyePosition(fromViewMatrix viewMatrix: simd_float4x4) -> simd_float3 { + let eyeTransform = simd_inverse(viewMatrix) + return simd_float3(eyeTransform.columns.3.x, eyeTransform.columns.3.y, eyeTransform.columns.3.z) +} + +/// The camera position shading uses: in XR, the eye being drawn, so highlights and +/// Fresnel sit at the right depth for each eye; otherwise the camera's own position. +/// Already folded through SceneRootTransform like every other shading camera position. +@inline(__always) +func shadingCameraPosition(_ cameraComponent: CameraComponent) -> simd_float3 { + SceneRootTransform.shared.effectiveCameraPosition(renderInfo.xrEyeCameraPosition ?? cameraComponent.localPosition) } @inline(__always) diff --git a/Sources/UntoldEngine/Renderer/UntoldEngine.swift b/Sources/UntoldEngine/Renderer/UntoldEngine.swift index 3a9372184..df278b6fa 100644 --- a/Sources/UntoldEngine/Renderer/UntoldEngine.swift +++ b/Sources/UntoldEngine/Renderer/UntoldEngine.swift @@ -693,6 +693,8 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { } public func draw(in view: MTKView) { + // Not an XR eye: shading uses the camera's own position. + renderInfo.xrEyeCameraPosition = nil if pendingResize { initSizeableResources() pendingResize = false @@ -835,6 +837,10 @@ public class UntoldRenderer: NSObject, MTKViewDelegate { } cameraComponent.viewSpace = viewMatrix + // The eye's own position: the view matrix is the inverse of the eye's camera + // transform. CameraComponent.localPosition stays at the head centre (see + // setXRCameraWorldPosition), which streaming wants and shading does not. + renderInfo.xrEyeCameraPosition = eyePosition(fromViewMatrix: viewMatrix) // Save this eye's view-projection for next frame's per-eye HZB culling, and the raw // view and projection it was built from for the Gaussian chunk cull, which folds in diff --git a/Tests/UntoldEngineTests/ShadingCameraPositionTests.swift b/Tests/UntoldEngineTests/ShadingCameraPositionTests.swift new file mode 100644 index 000000000..4e1857873 --- /dev/null +++ b/Tests/UntoldEngineTests/ShadingCameraPositionTests.swift @@ -0,0 +1,82 @@ +// +// ShadingCameraPositionTests.swift +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +@MainActor +final class ShadingCameraPositionTests: XCTestCase { + let srt = SceneRootTransform.shared + let eps: Float = 1e-4 + + override func setUp() async throws { + srt.position = .zero + srt.rotation = simd_quatf() + srt.scale = .one + srt.updateIfNeeded() + renderInfo.xrEyeCameraPosition = nil + } + + override func tearDown() async throws { + srt.position = .zero + srt.updateIfNeeded() + renderInfo.xrEyeCameraPosition = nil + } + + func testEyePositionIsTheTranslationOfTheInverseViewMatrix() { + // An eye 3.2 cm left of a head at (0, 1.6, 2), turned 30 degrees about Y. + let rotation = simd_float4x4(simd_quatf(angle: .pi / 6, axis: simd_float3(0, 1, 0))) + var eyeTransform = rotation + eyeTransform.columns.3 = simd_float4(-0.032, 1.6, 2, 1) + let viewMatrix = simd_inverse(eyeTransform) + + let position = eyePosition(fromViewMatrix: viewMatrix) + + XCTAssertEqual(position.x, -0.032, accuracy: eps) + XCTAssertEqual(position.y, 1.6, accuracy: eps) + XCTAssertEqual(position.z, 2, accuracy: eps) + } + + func testOutsideXRShadingUsesTheCameraPosition() { + let camera = CameraComponent() + camera.localPosition = simd_float3(1, 2, 3) + + let position = shadingCameraPosition(camera) + + XCTAssertEqual(position.x, 1, accuracy: eps) + XCTAssertEqual(position.y, 2, accuracy: eps) + XCTAssertEqual(position.z, 3, accuracy: eps) + } + + func testInXRShadingUsesTheEyeBeingDrawnNotTheHeadCentre() { + let camera = CameraComponent() + camera.localPosition = simd_float3(0, 1.6, 0) + renderInfo.xrEyeCameraPosition = simd_float3(0.032, 1.6, 0) + + let position = shadingCameraPosition(camera) + + XCTAssertEqual(position.x, 0.032, accuracy: eps) + } + + func testTheEyePositionGoesThroughTheSceneRootLikeTheCameraDid() { + let camera = CameraComponent() + renderInfo.xrEyeCameraPosition = simd_float3(0.032, 1.6, 0) + srt.position = simd_float3(5, 0, 0) + srt.updateIfNeeded() + + let expected = srt.effectiveCameraPosition(simd_float3(0.032, 1.6, 0)) + let position = shadingCameraPosition(camera) + + XCTAssertEqual(position.x, expected.x, accuracy: eps) + XCTAssertEqual(position.y, expected.y, accuracy: eps) + XCTAssertEqual(position.z, expected.z, accuracy: eps) + } +} From c33fca8123ce5fa99a4043a79da98066d728f03b Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Sat, 10 Oct 2026 02:03:41 +0200 Subject: [PATCH 09/11] [Bugfix] Keep a model's hidden parts hidden at its coarse LOD levels (#1324) * [Bugfix] Keep a model's hidden parts hidden at its coarse LOD levels A model's meshes are simplified one by one, each within the allowance of the level, and a part a short way behind another (a car's inner skin 0.8 mm under its roof, its door panels behind its doors) shows through once the allowance exceeds the gap: the parked cars of a BIM site grew black patches with the distance. The cook now sinks what is hidden before each level is simplified (UntoldMeshLODHiddenParts). A vertex is hidden when its own side is covered (the rays along its normal and in a cone round it all meet another face of the model) and a seen face of another, solid mesh is in front of it within three allowances; it is moved away from that face, along the face's normal, by twice the allowance where it touches the face and by less up to the edge of reach, so that the two cannot cross. What is seen is never moved; nothing is sunk away from glass; a sink stops two allowances short of any seen face in its way; the sinks are averaged over each connected piece, with the seen vertices held still, so that a piece hidden in one part and seen in another bends instead of tearing; and a piece that is sunk for the most part is simplified on its own share of its mesh's target, since bent by its sinking it would otherwise keep its triangles and take those of the pieces beside it. A level also gives every vertex whose normal is more than 30 degrees from the faces around it (a vertex kept from a groove that is gone) the normal of those faces, turned to the side of the vertex's own so that a mesh wound the other way round keeps its normals pointing out; a hard edge keeps both. On the site's pack (4,779 models) the cook takes 21 s instead of 6, the coarsest levels hold 3.4 % more triangles, the levels' errors do not change, and the cars are clean at every level. * [Chores] Satisfy the CI lint and the strict-concurrency guardrail CI's SwiftFormat 0.60.1 wants a key path for the trivial map closure and reads the `self.` of `let rings = self.rings(for:)` as redundant, where it told the local from the method: the method is now `cellRings(for:)`. CI's Swift 6.3.3 warns of a `var` captured by the closure `concurrentPerform` runs, which the guardrail counts as a failure: the covered vertices are now a `let` built by a filter. No behaviour changes; the cooker suite passes as before. --------- Co-authored-by: Javier Segura --- .../UntoldMeshLODCooker.swift | 73 +- .../UntoldMeshLODHiddenParts.swift | 894 ++++++++++++++++++ .../UntoldMeshLODSimplifier.swift | 107 ++- .../UntoldMeshLODCookerTests.swift | 611 +++++++++++- docs/API/UsingLODSystem.md | 12 +- docs/API/UsingUntoldEngineCLI.md | 9 +- 6 files changed, 1686 insertions(+), 20 deletions(-) create mode 100644 Sources/UntoldEngineMeshCook/UntoldMeshLODHiddenParts.swift diff --git a/Sources/UntoldEngineMeshCook/UntoldMeshLODCooker.swift b/Sources/UntoldEngineMeshCook/UntoldMeshLODCooker.swift index ec9b34807..64279f0ca 100644 --- a/Sources/UntoldEngineMeshCook/UntoldMeshLODCooker.swift +++ b/Sources/UntoldEngineMeshCook/UntoldMeshLODCooker.swift @@ -137,6 +137,9 @@ public enum UntoldMeshLODCooker { /// level's triangle count). Vertices that two meshes of the model share stay in /// place, so material boundaries do not open. The texture seams of a textured mesh /// keep their texture coordinates, unless they hold a level well above its target. + /// A part hidden a short way behind another (a car's headliner under its roof) is + /// sunk behind it before each level, so that the two do not cross + /// (`UntoldMeshLODHiddenParts`). @discardableResult public static func cookChain(forModelAt modelURL: URL, options: UntoldMeshLODOptions = UntoldMeshLODOptions()) throws -> UntoldMeshLODChain { try options.validate() @@ -177,20 +180,16 @@ public enum UntoldMeshLODCooker { return skip(.belowMinimumTriangles) } - var unpacked: [UntoldLODMesh] - let transforms: [simd_float4x4] - do { - let vertexChunk = try reader.readChunkData(.vertexData, from: fileData, entries: decoded.chunks) - let indexChunk = try reader.readChunkData(.indexData, from: fileData, entries: decoded.chunks) - unpacked = try decoded.meshes.map { try unpack($0, vertexChunk: vertexChunk, indexChunk: indexChunk) } - transforms = try meshTransforms(decoded) - } catch { - throw UntoldMeshLODError.unreadableModel(path: modelURL.path, reason: String(describing: error)) - } - applyVertexFlags(to: &unpacked, transforms: transforms, textured: decoded.meshes.map { usesTextures($0, in: decoded) }) - let meshes = unpacked + let (meshes, transforms, diameter) = try unpackGeometry(of: decoded, fileData: fileData, path: modelURL.path) let parts = meshes.map(UntoldLODMeshParts.init) - let diameter = modelDiameter(meshes: meshes, transforms: transforms) + // The vertices hidden a short way behind another mesh (a headliner under its + // roof): each level sinks them behind it by its allowance before it simplifies. + let coarsestTriangles = max(Float(triangleCount) * (options.ratios.min() ?? 1), 1) + let hiddenParts = UntoldMeshLODHiddenParts( + meshes: meshes, parts: parts, transforms: transforms, + glass: decoded.meshes.map { isGlass($0, in: decoded) }, + diameter: diameter, coarsestAllowance: diameter / coarsestTriangles.squareRoot() + ) let scales = transforms.map(largestAxisScale) var levels: [UntoldMeshLODLevel] = [] @@ -201,6 +200,8 @@ public enum UntoldMeshLODCooker { // triangles are when it takes over. let plannedTriangles = max(Float(triangleCount) * ratio, 1) let errorLimit = diameter / plannedTriangles.squareRoot() + let levelMeshes = hiddenParts.sunk(meshes, allowance: errorLimit) + let sunkPieces = hiddenParts.hiddenPieces var meshLevels = [UntoldLODMeshLevel?](repeating: nil, count: meshes.count) meshLevels.withUnsafeMutableBufferPointer { results in @@ -209,10 +210,11 @@ public enum UntoldMeshLODCooker { DispatchQueue.concurrentPerform(iterations: meshes.count) { index in let scale = scales[index] results[index] = UntoldMeshLODSimplifier.level( - of: meshes[index], + of: levelMeshes[index], parts: parts[index], ratio: ratio, - errorLimit: scale > 0 ? errorLimit / scale : errorLimit + errorLimit: scale > 0 ? errorLimit / scale : errorLimit, + sunkPieces: sunkPieces[index] ) } } @@ -245,6 +247,37 @@ public enum UntoldMeshLODCooker { ) } + /// The meshes of a decoded model with their simplifier flags, the transform that takes + /// each to model space, and the model's diameter. + private static func unpackGeometry(of decoded: UntoldDecodedAsset, fileData: Data, path: String) throws -> ([UntoldLODMesh], [simd_float4x4], Float) { + var unpacked: [UntoldLODMesh] + let transforms: [simd_float4x4] + do { + let reader = UntoldReader() + let vertexChunk = try reader.readChunkData(.vertexData, from: fileData, entries: decoded.chunks) + let indexChunk = try reader.readChunkData(.indexData, from: fileData, entries: decoded.chunks) + unpacked = try decoded.meshes.map { try unpack($0, vertexChunk: vertexChunk, indexChunk: indexChunk) } + transforms = try meshTransforms(decoded) + } catch { + throw UntoldMeshLODError.unreadableModel(path: path, reason: String(describing: error)) + } + applyVertexFlags(to: &unpacked, transforms: transforms, textured: decoded.meshes.map { usesTextures($0, in: decoded) }) + return (unpacked, transforms, modelDiameter(meshes: unpacked, transforms: transforms)) + } + + /// The meshes of the model at `modelURL` as the cook sees them (for the tests). + static func unpackedMeshes(ofModelAt modelURL: URL) throws -> ([UntoldLODMesh], [simd_float4x4], Float) { + let fileData: Data + let decoded: UntoldDecodedAsset + do { + fileData = try Data(contentsOf: modelURL, options: .mappedIfSafe) + decoded = try UntoldReader().readAsset(from: fileData) + } catch { + throw UntoldMeshLODError.unreadableModel(path: modelURL.path, reason: String(describing: error)) + } + return try unpackGeometry(of: decoded, fileData: fileData, path: modelURL.path) + } + /// The screen size at which `triangleCount` triangles are about `pixelsPerTriangle` /// pixels each: the model's bounding square is `screenSize * referenceViewportHeight` /// pixels on a side. @@ -387,6 +420,16 @@ public enum UntoldMeshLODCooker { ].contains { $0 != UntoldFormat.invalidIndex } } + /// Whether the material of `mesh` lets through what is behind it: it transmits, or its + /// base colour is not opaque. + private static func isGlass(_ mesh: UntoldMeshRecordV1, in decoded: UntoldDecodedAsset) -> Bool { + guard mesh.materialIndex != UntoldFormat.invalidIndex, Int(mesh.materialIndex) < decoded.materials.count else { + return false + } + let material = decoded.materials[Int(mesh.materialIndex)] + return material.transmissionFactor > 0 || material.baseColorFactor.w < 1 + } + /// Sets the simplifier flags of every mesh: a vertex whose position another mesh of /// the model also has is locked (the meshes are the materials of one surface and /// must keep meeting there), and a vertex on a texture seam of a textured mesh keeps diff --git a/Sources/UntoldEngineMeshCook/UntoldMeshLODHiddenParts.swift b/Sources/UntoldEngineMeshCook/UntoldMeshLODHiddenParts.swift new file mode 100644 index 000000000..6dcc0ad66 --- /dev/null +++ b/Sources/UntoldEngineMeshCook/UntoldMeshLODHiddenParts.swift @@ -0,0 +1,894 @@ +// +// UntoldMeshLODHiddenParts.swift +// UntoldEngineMeshCook +// +// The parts of a model that lie hidden a short way behind another, like a car's +// headliner under its roof, and how they are sunk before a level is simplified so +// that they stay hidden. +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import CMeshOptimizer +import Foundation +import simd + +/// Finds the vertices of a model that lie hidden a short way behind the surfaces of its +/// other meshes, and sinks them behind those surfaces before a level is simplified. +/// +/// Every mesh of a model is simplified on its own, within the allowance of the level +/// (one triangle's size). A car's headliner runs a millimetre under its roof, its door +/// panels a centimetre or two behind its doors: once the allowance exceeds the gap, the +/// coarse triangles of the roof cut below the headliner's and the dark part shows +/// through the paint. A vertex is hidden when its own side is covered (the rays along +/// its normal and in a cone round it all meet another face of the model) and it lies +/// behind a face of another, solid mesh that is seen, within reach of the level's +/// allowance: the face's nearest point in front of the vertex, the way the face points, +/// not beside it. Such a vertex is moved away from that face, along the face's normal, +/// so that it ends twice the allowance behind it: the face may then come in by the +/// allowance and the hidden part go out by it without the two crossing. The move stops +/// two allowances short of any seen face in its way, so that a piece between two seen +/// surfaces that face away from each other (a car's inner wing, between the paint and +/// the wheel-well liner) stays between them. A vertex that is seen is never moved, so +/// what a level draws from outside is the plain simplification of the model; and the +/// sinks are averaged over each piece (a connected component of the mesh) before they +/// are applied, so that a piece that is hidden in one part and seen in another (a +/// car's inner skin, whose wheel wells show through the arches) bends gradually from +/// the one to the other instead of tearing where they meet. Glass covers what is behind +/// it for the purpose of hiding, but nothing is sunk away from glass: what is behind a +/// headlight's lens or a windscreen is seen through it, and stays where it is. A part +/// in front of another (a ball on a floor) is seen on its own side and stays. A vertex +/// two meshes share is sunk only when it is hidden in both, and the same way in both, so +/// that their border stays closed. Aggregates (a canopy of loose cards) neither hide +/// nor are hidden: their pieces are thinned, not bent. +final class UntoldMeshLODHiddenParts { + /// How far behind a seen face a hidden vertex that touches it is put, as a multiple + /// of the allowance: the face may come in by one and the hidden part go out by one. + static let depthShare: Float = 2 + /// How far from a seen face a hidden vertex is still moved, as a multiple of the + /// allowance. From touching to this distance the move tapers off, so that a hidden + /// part keeps a share of its thickness and joins its vertices beyond reach without + /// a step. + static let reachShare: Float = 3 + /// The most faces, each facing its own way, a vertex is sunk from: a part in a corner + /// moves away from every face of the corner. + static let directionsPerVertex = 3 + /// How many times the sinks of a piece are averaged with their neighbours' before + /// they are applied. Each vertex is sunk from the faces it happens to be nearest, + /// and neighbours can differ in which; left as they are the sinks bend a piece into + /// creases that a level then spends its triangles on. Averaged, the piece moves as + /// one surface. + static let smoothingPasses = 8 + /// Two faces face their own ways when the cosine between their normals is under this. + static let distinctDirectionCosine: Float = 0.5 + /// A vertex nearer a face than this share of the model's diameter touches it, and is + /// on neither side of it (a ball on a floor). + static let touchingShare: Float = 1e-5 + /// The share of a piece's vertices that must be sunk for the piece to be simplified + /// on its own share of its mesh's target: bent by its sinking, it would otherwise + /// keep its triangles and take those of the pieces beside it. + static let sunkPieceShare: Float = 0.5 + /// A vertex is behind a face when the face's nearest point lies in front of it within + /// 45 degrees of the way the face points: the cosine of that angle. A vertex beside + /// a face, past its edge, is not behind it, nor is one under a face farther round a + /// curved shell than the one that covers it. + static let behindCosine: Float = 0.7 + + /// A seen face a hidden vertex is sunk from. + struct Reference: Equatable { + /// The way the face points, in model space. + var normal: SIMD3 + /// The vertex's distance to the face in the model. + var distance: Float + } + + /// A hidden distinct vertex of a mesh with the faces it is sunk from, nearest first. + struct HiddenVertex { + var vertex: Int + var references: [Reference] + } + + /// For each mesh, its hidden vertices (one per position); empty for a mesh that has none. + let hiddenVertices: [[HiddenVertex]] + /// For each mesh, which of its pieces (the components of its parts) are sunk for the + /// most part. + let hiddenPieces: [[Bool]] + /// For each mesh with a hidden vertex, the distinct vertices of the pieces that have + /// one, which seen vertices among them must not move, and for each distinct vertex + /// the distinct vertices it shares a triangle with. + private let pieceVertices: [[Int]] + private let seenVertices: [[Bool]] + private let neighbours: [[[Int32]]] + private let transforms: [simd_float4x4] + private let inverseTransforms: [simd_float4x4] + /// The model's faces, and which of them a sunk vertex must not pass; nil when + /// nothing is hidden. + let grid: ModelGrid? + let referenceTriangles: [Bool] + let touching: Float + + /// How many positions of the model are hidden behind another mesh within reach of + /// the coarsest allowance. + var count: Int { + hiddenVertices.reduce(0) { $0 + $1.count } + } + + var isEmpty: Bool { + hiddenVertices.allSatisfy(\.isEmpty) + } + + /// Finds the hidden vertices of the model within `reachShare` times + /// `coarsestAllowance` (model units) of a seen face of another mesh. `glass` flags + /// the meshes whose material lets through what is behind it. + init(meshes: [UntoldLODMesh], parts: [UntoldLODMeshParts], transforms: [simd_float4x4], glass: [Bool], diameter: Float, coarsestAllowance: Float) { + self.transforms = transforms + inverseTransforms = transforms.map(\.inverse) + let reach = Self.reachShare * coarsestAllowance + let eligible = meshes.indices.filter { index in + meshes[index].vertexCount > 0 && meshes[index].triangleCount > 0 && parts[index].clutterIndices.isEmpty + } + guard eligible.count > 1, reach > 0, reach.isFinite, diameter > 0 else { + hiddenVertices = meshes.map { _ in [] } + hiddenPieces = parts.map { [Bool](repeating: false, count: $0.componentCount) } + pieceVertices = meshes.map { _ in [] } + seenVertices = meshes.map { _ in [] } + neighbours = meshes.map { _ in [] } + grid = nil + referenceTriangles = [] + touching = 0 + return + } + let grid = ModelGrid(meshes: meshes, transforms: transforms, included: eligible, cellSize: max(reach / 2, diameter / 96)) + let touching = Self.touchingShare * diameter + self.touching = touching + + // Only the vertices with a solid mesh within reach are measured: the rays and + // the searches are the cost of the cook. + var solidMeshes = [Bool](repeating: false, count: meshes.count) + for meshIndex in eligible where !glass[meshIndex] { + solidMeshes[meshIndex] = true + } + guard solidMeshes.contains(true) else { + hiddenVertices = meshes.map { _ in [] } + hiddenPieces = parts.map { [Bool](repeating: false, count: $0.componentCount) } + pieceVertices = meshes.map { _ in [] } + seenVertices = meshes.map { _ in [] } + neighbours = meshes.map { _ in [] } + self.grid = nil + self.referenceTriangles = [] + return + } + let near = grid.verticesNear(meshes: solidMeshes, within: reach) + let seen = grid.seen(near, offset: touching) + // The faces a hidden vertex may be sunk from: those of a solid mesh with a seen + // corner. + let referenceTriangles = grid.triangleFlags { mesh, corners in + solidMeshes[mesh] && (seen[corners.x] == true || seen[corners.y] == true || seen[corners.z] == true) + } + self.grid = grid + self.referenceTriangles = referenceTriangles + let shared = Self.sharedVertices(meshes: meshes, grid: grid) + + var hidden = [[HiddenVertex]](repeating: [], count: meshes.count) + for meshIndex in eligible { + let mesh = meshes[meshIndex] + // A vertex other meshes have too is sunk only when it is hidden in each of + // them, so that it moves the same way in all and their border stays closed. + let covered = (0 ..< mesh.vertexCount).filter { vertex in + guard Int(mesh.positionRemap[vertex]) == vertex else { return false } + guard let global = grid.globalVertex(mesh: meshIndex, vertex: vertex), near[global], seen[global] == false else { return false } + guard let copies = shared[Self.key(grid.position(global))] else { return true } + return copies.allSatisfy { seen[$0] == false } + } + guard !covered.isEmpty else { continue } + var references = [[Reference]](repeating: [], count: covered.count) + references.withUnsafeMutableBufferPointer { buffer in + nonisolated(unsafe) let buffer = buffer + DispatchQueue.concurrentPerform(iterations: covered.count) { index in + guard let global = grid.globalVertex(mesh: meshIndex, vertex: covered[index]) else { return } + buffer[index] = grid.references( + near: grid.position(global), within: reach, touching: touching, behindCosine: Self.behindCosine, + ofTriangles: referenceTriangles, limit: Self.directionsPerVertex, distinctCosine: Self.distinctDirectionCosine + ) + } + } + hidden[meshIndex] = zip(covered, references).compactMap { vertex, found in + found.isEmpty ? nil : HiddenVertex(vertex: vertex, references: found) + } + } + hiddenVertices = hidden + + // The pieces with a hidden vertex: their vertices and neighbours for the + // smoothing, which of those are seen, and whether the piece is sunk for the most + // part. + var hiddenPieces: [[Bool]] = parts.map { [Bool](repeating: false, count: $0.componentCount) } + var pieceVertices = [[Int]](repeating: [], count: meshes.count) + var seenVertices = [[Bool]](repeating: [], count: meshes.count) + var neighbours = [[[Int32]]](repeating: [], count: meshes.count) + for meshIndex in eligible where !hidden[meshIndex].isEmpty { + let mesh = meshes[meshIndex] + let part = parts[meshIndex] + var sunkOfPiece = [Int](repeating: 0, count: part.componentCount) + for hiddenVertex in hidden[meshIndex] { + let component = Int(part.componentOfVertex[hiddenVertex.vertex]) + if component >= 0, component < sunkOfPiece.count { + sunkOfPiece[component] += 1 + } + } + var sizeOfPiece = [Int](repeating: 0, count: part.componentCount) + for vertex in 0 ..< mesh.vertexCount where Int(mesh.positionRemap[vertex]) == vertex { + let component = Int(part.componentOfVertex[vertex]) + if component >= 0, component < sizeOfPiece.count { + sizeOfPiece[component] += 1 + } + } + func pieceHasHidden(_ vertex: Int) -> Bool { + let component = Int(part.componentOfVertex[vertex]) + return component >= 0 && component < sunkOfPiece.count && sunkOfPiece[component] > 0 + } + for component in sunkOfPiece.indices where sizeOfPiece[component] > 0 { + hiddenPieces[meshIndex][component] = Float(sunkOfPiece[component]) >= Float(sizeOfPiece[component]) * Self.sunkPieceShare + } + pieceVertices[meshIndex] = (0 ..< mesh.vertexCount).filter { Int(mesh.positionRemap[$0]) == $0 && pieceHasHidden($0) } + var isSeen = [Bool](repeating: false, count: mesh.vertexCount) + for vertex in pieceVertices[meshIndex] { + if let global = grid.globalVertex(mesh: meshIndex, vertex: vertex), seen[global] != false { + isSeen[vertex] = true + } + } + seenVertices[meshIndex] = isSeen + var edges = Set() + for triangle in stride(from: 0, to: mesh.indices.count, by: 3) { + let corners = (0 ..< 3).map { Int(mesh.positionRemap[Int(mesh.indices[triangle + $0])]) } + guard pieceHasHidden(corners[0]) else { continue } + for (a, b) in [(corners[0], corners[1]), (corners[1], corners[2]), (corners[2], corners[0])] where a != b { + edges.insert(UInt64(min(a, b)) << 32 | UInt64(max(a, b))) + } + } + var lists = [[Int32]](repeating: [], count: mesh.vertexCount) + for edge in edges { + let a = Int(edge >> 32), b = Int(edge & 0xFFFF_FFFF) + lists[a].append(Int32(b)) + lists[b].append(Int32(a)) + } + neighbours[meshIndex] = lists + } + self.hiddenPieces = hiddenPieces + self.seenVertices = seenVertices + self.pieceVertices = pieceVertices + self.neighbours = neighbours + } + + /// The meshes with their hidden vertices sunk for a level whose allowance is + /// `allowance` (model units): a hidden vertex within `reachShare` allowances of a + /// seen face moves away from it along the face's normal, by `depthShare` allowances + /// when it touches the face and by less up to the edge of reach; the sinks are + /// averaged over each piece (`smoothingPasses`), and none goes farther than + /// `depthShare` allowances short of the first seen face in its way. A mesh with no + /// hidden vertex within reach is returned as it is. + func sunk(_ meshes: [UntoldLODMesh], allowance: Float) -> [UntoldLODMesh] { + guard allowance > 0, allowance.isFinite, let grid else { return meshes } + let reach = Self.reachShare * allowance + let depth = Self.depthShare * allowance + let lock = UInt8(meshopt_SimplifyVertex_Lock) + var result = meshes + for meshIndex in meshes.indices where !hiddenVertices[meshIndex].isEmpty { + let mesh = meshes[meshIndex] + let transform = transforms[meshIndex] + let inverse = inverseTransforms[meshIndex] + // The sink of each hidden vertex, in model space. + var sinks = [SIMD3](repeating: .zero, count: mesh.vertexCount) + for hidden in hiddenVertices[meshIndex] { + for reference in hidden.references where reference.distance < reach { + sinks[hidden.vertex] -= reference.normal * ((reach - reference.distance) * (depth / reach)) + } + } + // Averaged over each piece, so that it moves as one surface and bends + // gradually to the vertices of it that are seen, which do not move. A vertex + // another mesh shares keeps its own, the same in both meshes. + let vertices = pieceVertices[meshIndex] + let isSeen = seenVertices[meshIndex] + let lists = neighbours[meshIndex] + if !lists.isEmpty { + for _ in 0 ..< Self.smoothingPasses { + var next = sinks + for vertex in vertices where !isSeen[vertex] && (mesh.lockFlags.isEmpty || mesh.lockFlags[vertex] & lock == 0) { + var sum = sinks[vertex] + var count: Float = 1 + for neighbour in lists[vertex] { + sum += sinks[Int(neighbour)] + count += 1 + } + next[vertex] = sum / count + } + sinks = next + } + } + var moved: [Int: SIMD3] = [:] + for vertex in vertices where !isSeen[vertex] { + var length = simd_length(sinks[vertex]) + guard length > 0 else { continue } + let start = simd_make_float3(simd_mul(transform, SIMD4(mesh.position(vertex), 1))) + let direction = sinks[vertex] / length + if let blocked = grid.nearestHit(from: start + direction * touching, direction: direction, within: length + depth, ofTriangles: referenceTriangles) { + length = min(length, max(blocked + touching - depth, 0)) + guard length > 0 else { continue } + } + moved[vertex] = simd_make_float3(simd_mul(inverse, SIMD4(start + direction * length, 1))) + } + guard !moved.isEmpty else { continue } + // Every vertex at the position moves with it: the copies of a seam or a hard + // edge stay together, and the position remap stays true. + var positions = mesh.positions + var vertexData = mesh.vertexData + var lower = mesh.boundsMin + var upper = mesh.boundsMax + vertexData.withUnsafeMutableBytes { raw in + for vertex in 0 ..< mesh.vertexCount { + guard let position = moved[Int(mesh.positionRemap[vertex])] else { continue } + positions[vertex * 3] = position.x + positions[vertex * 3 + 1] = position.y + positions[vertex * 3 + 2] = position.z + let record = vertex * UntoldLODMesh.vertexStride + raw.storeBytes(of: position.x.bitPattern.littleEndian, toByteOffset: record, as: UInt32.self) + raw.storeBytes(of: position.y.bitPattern.littleEndian, toByteOffset: record + 4, as: UInt32.self) + raw.storeBytes(of: position.z.bitPattern.littleEndian, toByteOffset: record + 8, as: UInt32.self) + lower = simd_min(lower, position) + upper = simd_max(upper, position) + } + } + var copy = mesh + copy.positions = positions + copy.vertexData = vertexData + copy.boundsMin = lower + copy.boundsMax = upper + result[meshIndex] = copy + } + return result + } + + // MARK: - Shared vertices + + /// For every model-space position that two or more meshes have a vertex at (the + /// locked vertices), the first vertex of each of those meshes there, as the grid + /// numbers them. + private static func sharedVertices(meshes: [UntoldLODMesh], grid: ModelGrid) -> [SIMD3: [Int]] { + let lock = UInt8(meshopt_SimplifyVertex_Lock) + var copies: [SIMD3: [Int]] = [:] + for (meshIndex, mesh) in meshes.enumerated() where !mesh.lockFlags.isEmpty { + for vertex in 0 ..< mesh.vertexCount where mesh.lockFlags[vertex] & lock != 0 && Int(mesh.positionRemap[vertex]) == vertex { + guard let global = grid.globalVertex(mesh: meshIndex, vertex: vertex) else { continue } + copies[key(grid.position(global)), default: []].append(global) + } + } + return copies.filter { $0.value.count > 1 } + } + + static func key(_ point: SIMD3) -> SIMD3 { + // Adding zero folds -0 into +0. + let folded = point + SIMD3(repeating: 0) + return SIMD3(folded.x.bitPattern, folded.y.bitPattern, folded.z.bitPattern) + } + + // MARK: - The model's triangles on a grid + + /// The triangles of the model's meshes in model space, binned on a uniform grid, so + /// that the faces near a point and the faces along a ray are found without a pass + /// over them all. + final class ModelGrid: Sendable { + /// Model-space position of every vertex of the included meshes, mesh after mesh. + private let positions: [SIMD3] + /// Model-space normal of every vertex, unit length (zero when the mesh has none). + private let normals: [SIMD3] + /// The first vertex at the same position within the mesh, as a global index. + private let firstAtPosition: [Int32] + private let meshOfVertex: [Int32] + /// Where each mesh's vertices start; -1 for a mesh that is not included. + private let vertexStart: [Int] + /// Corners of every triangle as global vertex indices. + private let triangles: [SIMD3] + /// The way each triangle's face points, turned to agree with its vertices. + private let faceNormals: [SIMD3] + private let meshOfTriangle: [Int32] + private let origin: SIMD3 + private let cellStep: SIMD3 + private let cells: SIMD3 + private let cellStart: [Int] + private let cellTriangles: [Int32] + /// Per cell, one bit per mesh (modulo 64) that has a triangle in it. + private let cellMeshes: [UInt64] + + init(meshes: [UntoldLODMesh], transforms: [simd_float4x4], included: [Int], cellSize: Float) { + var positions: [SIMD3] = [] + var normals: [SIMD3] = [] + var firstAtPosition: [Int32] = [] + var meshOfVertex: [Int32] = [] + var vertexStart = [Int](repeating: -1, count: meshes.count) + var triangles: [SIMD3] = [] + var faceNormals: [SIMD3] = [] + var meshOfTriangle: [Int32] = [] + var lower = SIMD3(repeating: .greatestFiniteMagnitude) + var upper = SIMD3(repeating: -.greatestFiniteMagnitude) + for meshIndex in included { + let mesh = meshes[meshIndex] + let transform = transforms[meshIndex] + let rotation = Self.normalTransform(of: transform) + vertexStart[meshIndex] = positions.count + let base = Int32(positions.count) + for vertex in 0 ..< mesh.vertexCount { + let point = simd_make_float3(simd_mul(transform, SIMD4(mesh.position(vertex), 1))) + positions.append(point) + let normal = simd_mul(rotation, SIMD3(mesh.normals[vertex * 3], mesh.normals[vertex * 3 + 1], mesh.normals[vertex * 3 + 2])) + let length = simd_length(normal) + normals.append(length > 0 ? normal / length : .zero) + firstAtPosition.append(base + Int32(mesh.positionRemap[vertex])) + meshOfVertex.append(Int32(meshIndex)) + lower = simd_min(lower, point) + upper = simd_max(upper, point) + } + for triangle in 0 ..< mesh.triangleCount { + let corners = SIMD3( + base + Int32(mesh.indices[triangle * 3]), + base + Int32(mesh.indices[triangle * 3 + 1]), + base + Int32(mesh.indices[triangle * 3 + 2]) + ) + let a = positions[Int(corners.x)], b = positions[Int(corners.y)], c = positions[Int(corners.z)] + var normal = simd_cross(b - a, c - a) + let length = simd_length(normal) + normal = length > 0 ? normal / length : .zero + // A mirrored transform turns the winding round; the vertices say which + // side the face is on. + let vertexNormal = normals[Int(corners.x)] + normals[Int(corners.y)] + normals[Int(corners.z)] + if simd_dot(normal, vertexNormal) < 0 { + normal = -normal + } + triangles.append(corners) + faceNormals.append(normal) + meshOfTriangle.append(Int32(meshIndex)) + } + } + + let size = max(cellSize, 1e-6) + let origin = lower - size + let extent = upper - lower + 2 * size + let counts = SIMD3( + min(max(Int((extent.x / size).rounded(.up)), 1), 128), + min(max(Int((extent.y / size).rounded(.up)), 1), 128), + min(max(Int((extent.z / size).rounded(.up)), 1), 128) + ) + let cellStep = SIMD3(extent.x / Float(counts.x), extent.y / Float(counts.y), extent.z / Float(counts.z)) + let cellCount = counts.x * counts.y * counts.z + func cellRange(_ lo: SIMD3, _ hi: SIMD3) -> (SIMD3, SIMD3) { + let a = (lo - origin) / cellStep + let b = (hi - origin) / cellStep + let first = SIMD3( + min(max(Int(a.x), 0), counts.x - 1), min(max(Int(a.y), 0), counts.y - 1), min(max(Int(a.z), 0), counts.z - 1) + ) + let last = SIMD3( + min(max(Int(b.x), 0), counts.x - 1), min(max(Int(b.y), 0), counts.y - 1), min(max(Int(b.z), 0), counts.z - 1) + ) + return (first, last) + } + var perCell = [Int32](repeating: 0, count: cellCount) + var ranges: [(SIMD3, SIMD3)] = [] + ranges.reserveCapacity(triangles.count) + for corners in triangles { + let a = positions[Int(corners.x)], b = positions[Int(corners.y)], c = positions[Int(corners.z)] + let r = cellRange(simd_min(simd_min(a, b), c), simd_max(simd_max(a, b), c)) + ranges.append(r) + for z in r.0.z ... r.1.z { + for y in r.0.y ... r.1.y { + for x in r.0.x ... r.1.x { + perCell[(z * counts.y + y) * counts.x + x] += 1 + } + } + } + } + var start = [Int](repeating: 0, count: cellCount + 1) + for cell in 0 ..< cellCount { + start[cell + 1] = start[cell] + Int(perCell[cell]) + } + var fill = start + var binned = [Int32](repeating: 0, count: start[cellCount]) + var meshBits = [UInt64](repeating: 0, count: cellCount) + for (index, r) in ranges.enumerated() { + let bit = UInt64(1) << UInt64(Int(meshOfTriangle[index]) % 64) + for z in r.0.z ... r.1.z { + for y in r.0.y ... r.1.y { + for x in r.0.x ... r.1.x { + let cell = (z * counts.y + y) * counts.x + x + binned[fill[cell]] = Int32(index) + fill[cell] += 1 + meshBits[cell] |= bit + } + } + } + } + self.positions = positions + self.normals = normals + self.firstAtPosition = firstAtPosition + self.meshOfVertex = meshOfVertex + self.vertexStart = vertexStart + self.triangles = triangles + self.faceNormals = faceNormals + self.meshOfTriangle = meshOfTriangle + self.origin = origin + self.cellStep = cellStep + cells = counts + cellStart = start + cellTriangles = binned + cellMeshes = meshBits + } + + // MARK: Vertices + + var vertexCount: Int { + positions.count + } + + func globalVertex(mesh: Int, vertex: Int) -> Int? { + let start = vertexStart[mesh] + return start >= 0 ? start + vertex : nil + } + + func mesh(ofVertex vertex: Int) -> Int { + Int(meshOfVertex[vertex]) + } + + /// The mesh of a vertex and its index within that mesh. + func local(ofVertex vertex: Int) -> (mesh: Int, vertex: Int) { + let mesh = Int(meshOfVertex[vertex]) + return (mesh, vertex - vertexStart[mesh]) + } + + var triangleCount: Int { + triangles.count + } + + func mesh(ofTriangle triangle: Int) -> Int { + Int(meshOfTriangle[triangle]) + } + + /// A flag per triangle from its mesh and its corners (global vertex indices). + func triangleFlags(_ flag: (_ mesh: Int, _ corners: SIMD3) -> Bool) -> [Bool] { + triangles.indices.map { triangle in + let corners = triangles[triangle] + return flag(Int(meshOfTriangle[triangle]), SIMD3(Int(corners.x), Int(corners.y), Int(corners.z))) + } + } + + func position(_ vertex: Int) -> SIMD3 { + positions[vertex] + } + + /// For each vertex, whether a triangle of one of `meshes` lies within `reach` of + /// its cell. + func verticesNear(meshes: [Bool], within reach: Float) -> [Bool] { + var wanted: UInt64 = 0 + for (mesh, isWanted) in meshes.enumerated() where isWanted { + wanted |= UInt64(1) << UInt64(mesh % 64) + } + let rings = cellRings(for: reach) + // The meshes within `rings` cells of each cell, by a separable dilation. + var dilated = cellMeshes + for axis in 0 ..< 3 where rings[axis] > 0 { + let ring = rings[axis] + var next = dilated + for z in 0 ..< cells.z { + for y in 0 ..< cells.y { + for x in 0 ..< cells.x { + var bits: UInt64 = 0 + for step in -ring ... ring { + var c = SIMD3(x, y, z) + c[axis] += step + guard c[axis] >= 0, c[axis] < cells[axis] else { continue } + bits |= dilated[(c.z * cells.y + c.y) * cells.x + c.x] + } + next[(z * cells.y + y) * cells.x + x] = bits + } + } + } + dilated = next + } + var near = [Bool](repeating: false, count: positions.count) + for vertex in positions.indices { + guard let home = cell(of: positions[vertex]) else { continue } + near[vertex] = dilated[(home.z * cells.y + home.y) * cells.x + home.x] & wanted != 0 + } + return near + } + + /// The rays cast from a vertex to tell whether its side is seen: one along its + /// normal and this many round it, `coneAngle` from the normal. A wheel-arch + /// liner's normal points at the tyre, and the liner is seen through the arch all + /// the same. + static let coneRays = 8 + static let coneAngle: Float = .pi / 3 + + /// For each vertex flagged in `asked`, whether the ray along its normal, or one of + /// the cone of rays round it, escapes the model: true for a seen vertex, false for + /// a covered one, nil for a vertex not asked about or without a normal. The + /// vertices at one position (a hard edge, a seam) share the answer: the position + /// is seen when any of them is. + func seen(_ asked: [Bool], offset: Float) -> [Bool?] { + var result = [Bool?](repeating: nil, count: positions.count) + let list = positions.indices.filter { asked[Int(firstAtPosition[$0])] } + let cosine = cos(Self.coneAngle), sine = sin(Self.coneAngle) + result.withUnsafeMutableBufferPointer { buffer in + nonisolated(unsafe) let buffer = buffer + DispatchQueue.concurrentPerform(iterations: list.count) { index in + let vertex = list[index] + let normal = normals[vertex] + guard simd_length_squared(normal) > 0 else { return } + let from = positions[vertex] + normal * offset + if !rayHitsAnything(from: from, direction: normal) { + buffer[vertex] = true + return + } + let helper: SIMD3 = abs(normal.y) < 0.9 ? SIMD3(0, 1, 0) : SIMD3(1, 0, 0) + let side = simd_normalize(simd_cross(normal, helper)) + let up = simd_cross(normal, side) + for ray in 0 ..< Self.coneRays { + let angle = 2 * Float.pi * Float(ray) / Float(Self.coneRays) + let direction = normal * cosine + (side * cos(angle) + up * sin(angle)) * sine + if !rayHitsAnything(from: from, direction: direction) { + buffer[vertex] = true + return + } + } + buffer[vertex] = false + } + } + var byFirst: [Int: Bool] = [:] + for vertex in list { + guard let value = result[vertex] else { continue } + let first = Int(firstAtPosition[vertex]) + byFirst[first] = (byFirst[first] ?? false) || value + } + for vertex in positions.indices { + if let value = byFirst[Int(firstAtPosition[vertex])] { + result[vertex] = value + } + } + return result + } + + // MARK: Faces near a point + + /// The faces flagged in `ofTriangles` within `reach` of `point` that `point` lies + /// behind: the face's nearest point is farther than `touching` and in front of + /// `point`, within the angle whose cosine is `behindCosine` of the way the face + /// points. Nearest first, at most `limit` of them and each facing its own way (the + /// cosine between the normals of any two under `distinctCosine`). + func references( + near point: SIMD3, within reach: Float, touching: Float, behindCosine: Float, + ofTriangles: [Bool], limit: Int, distinctCosine: Float + ) -> [Reference] { + guard let home = cell(of: point) else { return [] } + let rings = cellRings(for: reach) + var candidates: [(distance: Float, normal: SIMD3)] = [] + for z in max(home.z - rings.z, 0) ... min(home.z + rings.z, cells.z - 1) { + for y in max(home.y - rings.y, 0) ... min(home.y + rings.y, cells.y - 1) { + for x in max(home.x - rings.x, 0) ... min(home.x + rings.x, cells.x - 1) { + for index in triangles(in: SIMD3(x, y, z)) { + let triangle = Int(index) + guard ofTriangles[triangle] else { continue } + let corners = triangles[triangle] + let normal = faceNormals[triangle] + let closest = Self.closestPoint( + to: point, onTriangle: positions[Int(corners.x)], positions[Int(corners.y)], positions[Int(corners.z)] + ) + let offset = point - closest + let distance = simd_length(offset) + guard distance <= reach, distance > touching, simd_dot(offset, normal) < -behindCosine * distance else { continue } + candidates.append((distance, normal)) + } + } + } + } + guard !candidates.isEmpty else { return [] } + candidates.sort { $0.distance < $1.distance } + var references: [Reference] = [] + for candidate in candidates where references.count < limit { + guard references.allSatisfy({ simd_dot($0.normal, candidate.normal) < distinctCosine }) else { continue } + references.append(Reference(normal: candidate.normal, distance: candidate.distance)) + } + return references + } + + // MARK: Rays + + /// Whether a ray from `start` along `direction` meets a triangle of the model + /// before it leaves the grid. + func rayHitsAnything(from start: SIMD3, direction: SIMD3) -> Bool { + guard simd_length_squared(direction) > 0 else { return false } + var hit = false + traverse(from: start, direction: direction) { cell, _ in + for index in triangles(in: cell) { + let corners = triangles[Int(index)] + if Self.rayHits(start, direction, positions[Int(corners.x)], positions[Int(corners.y)], positions[Int(corners.z)]) { + hit = true + return false + } + } + return true + } + return hit + } + + /// Visits the cells a ray from `start` along `direction` passes, in order, with the + /// distance along the ray at which it enters each, until `visit` returns false or + /// the ray leaves the grid. + private func traverse(from start: SIMD3, direction: SIMD3, _ visit: (SIMD3, Float) -> Bool) { + // Amanatides and Woo: from cell to cell along the ray. + var current: SIMD3 + var entered: Float = 0 + if let home = cell(of: start) { + current = home + } else { + let lower = origin + let upper = origin + cellStep * SIMD3(Float(cells.x), Float(cells.y), Float(cells.z)) + var tEnter: Float = 0 + var tExit: Float = .greatestFiniteMagnitude + for axis in 0 ..< 3 { + if abs(direction[axis]) < 1e-12 { + if start[axis] < lower[axis] || start[axis] > upper[axis] { + return + } + } else { + let t1 = (lower[axis] - start[axis]) / direction[axis] + let t2 = (upper[axis] - start[axis]) / direction[axis] + tEnter = max(tEnter, min(t1, t2)) + tExit = min(tExit, max(t1, t2)) + } + } + guard tEnter <= tExit, let home = cell(of: start + direction * (tEnter + 1e-6)) else { return } + current = home + entered = tEnter + } + var step = SIMD3(repeating: 0) + var tMax = SIMD3(repeating: .greatestFiniteMagnitude) + var tDelta = SIMD3(repeating: .greatestFiniteMagnitude) + for axis in 0 ..< 3 where abs(direction[axis]) > 1e-12 { + step[axis] = direction[axis] > 0 ? 1 : -1 + let boundary = origin[axis] + cellStep[axis] * Float(current[axis] + (direction[axis] > 0 ? 1 : 0)) + tMax[axis] = (boundary - start[axis]) / direction[axis] + tDelta[axis] = cellStep[axis] / abs(direction[axis]) + } + while visit(current, entered) { + // Into the next cell along the axis whose boundary comes first. + let axis = tMax.x < tMax.y ? (tMax.x < tMax.z ? 0 : 2) : (tMax.y < tMax.z ? 1 : 2) + current[axis] += step[axis] + guard current[axis] >= 0, current[axis] < cells[axis] else { return } + entered = tMax[axis] + tMax[axis] += tDelta[axis] + } + } + + /// How far past a triangle's edge, in its own coordinates, a ray still hits it: + /// a ray through a vertex or an edge of the model (a lining offset from its + /// surface sends one through every vertex) must not slip between the faces there. + private static let edgeTolerance: Float = 1e-4 + + /// The distance along a ray from `start` along `direction` (unit length) to the + /// nearest face flagged in `ofTriangles`, when there is one within `limit`. + func nearestHit(from start: SIMD3, direction: SIMD3, within limit: Float, ofTriangles: [Bool]) -> Float? { + guard simd_length_squared(direction) > 0 else { return nil } + var best: Float? + traverse(from: start, direction: direction) { cell, entry in + if let best, entry > best { + return false + } + guard entry <= limit else { return false } + for index in triangles(in: cell) where ofTriangles[Int(index)] { + let corners = triangles[Int(index)] + if let t = Self.rayDistance(start, direction, positions[Int(corners.x)], positions[Int(corners.y)], positions[Int(corners.z)]), + t <= limit, t < (best ?? .greatestFiniteMagnitude) + { + best = t + } + } + return true + } + return best + } + + private static func rayHits(_ start: SIMD3, _ direction: SIMD3, _ a: SIMD3, _ b: SIMD3, _ c: SIMD3) -> Bool { + rayDistance(start, direction, a, b, c) != nil + } + + /// The distance along the ray to the triangle, when the ray meets it ahead. + private static func rayDistance(_ start: SIMD3, _ direction: SIMD3, _ a: SIMD3, _ b: SIMD3, _ c: SIMD3) -> Float? { + // Möller and Trumbore, either side of the face. + let e1 = b - a, e2 = c - a + let p = simd_cross(direction, e2) + let det = simd_dot(e1, p) + guard abs(det) > 1e-12 else { return nil } + let f = 1 / det + let s = start - a + let u = f * simd_dot(s, p) + guard u >= -edgeTolerance, u <= 1 + edgeTolerance else { return nil } + let q = simd_cross(s, e1) + let v = f * simd_dot(direction, q) + guard v >= -edgeTolerance, u + v <= 1 + edgeTolerance else { return nil } + let t = f * simd_dot(e2, q) + return t > 0 ? t : nil + } + + // MARK: Cells + + /// How many cells each way cover `reach`. + private func cellRings(for reach: Float) -> SIMD3 { + SIMD3( + Int((reach / cellStep.x).rounded(.up)), Int((reach / cellStep.y).rounded(.up)), Int((reach / cellStep.z).rounded(.up)) + ) + } + + private func cell(of point: SIMD3) -> SIMD3? { + let p = (point - origin) / cellStep + guard p.x >= 0, p.y >= 0, p.z >= 0 else { return nil } + let c = SIMD3(Int(p.x), Int(p.y), Int(p.z)) + guard c.x < cells.x, c.y < cells.y, c.z < cells.z else { return nil } + return c + } + + private func triangles(in cell: SIMD3) -> ArraySlice { + let index = (cell.z * cells.y + cell.y) * cells.x + cell.x + return cellTriangles[cellStart[index] ..< cellStart[index + 1]] + } + + /// What takes a normal of the mesh to model space: the inverse transpose of the + /// transform's upper 3x3 (the transform itself for a rotation and a uniform scale). + static func normalTransform(of transform: simd_float4x4) -> simd_float3x3 { + let upper = simd_float3x3( + simd_make_float3(transform.columns.0), + simd_make_float3(transform.columns.1), + simd_make_float3(transform.columns.2) + ) + let inverse = upper.inverse + return inverse.determinant.isFinite && inverse.determinant != 0 ? inverse.transpose : upper + } + + /// The point of the triangle `a b c` nearest to `p`. + static func closestPoint(to p: SIMD3, onTriangle a: SIMD3, _ b: SIMD3, _ c: SIMD3) -> SIMD3 { + // Ericson, Real-Time Collision Detection, closest point on a triangle. + let ab = b - a, ac = c - a, ap = p - a + let d1 = simd_dot(ab, ap), d2 = simd_dot(ac, ap) + if d1 <= 0, d2 <= 0 { + return a + } + let bp = p - b + let d3 = simd_dot(ab, bp), d4 = simd_dot(ac, bp) + if d3 >= 0, d4 <= d3 { + return b + } + let vc = d1 * d4 - d3 * d2 + if vc <= 0, d1 >= 0, d3 <= 0 { + let v = d1 / (d1 - d3) + return a + v * ab + } + let cp = p - c + let d5 = simd_dot(ab, cp), d6 = simd_dot(ac, cp) + if d6 >= 0, d5 <= d6 { + return c + } + let vb = d5 * d2 - d1 * d6 + if vb <= 0, d2 >= 0, d6 <= 0 { + let w = d2 / (d2 - d6) + return a + w * ac + } + let va = d3 * d6 - d5 * d4 + if va <= 0, d4 - d3 >= 0, d5 - d6 >= 0 { + let w = (d4 - d3) / ((d4 - d3) + (d5 - d6)) + return b + w * (c - b) + } + let denominator = 1 / (va + vb + vc) + let v = vb * denominator, w = vc * denominator + return a + ab * v + ac * w + } + } +} diff --git a/Sources/UntoldEngineMeshCook/UntoldMeshLODSimplifier.swift b/Sources/UntoldEngineMeshCook/UntoldMeshLODSimplifier.swift index b73697529..0179d9ad2 100644 --- a/Sources/UntoldEngineMeshCook/UntoldMeshLODSimplifier.swift +++ b/Sources/UntoldEngineMeshCook/UntoldMeshLODSimplifier.swift @@ -271,12 +271,14 @@ enum UntoldMeshLODSimplifier { /// Reduces `mesh` towards `ratio` of its triangles, deviating from it by at most /// `errorLimit` (mesh units). The result can hold more triangles than asked for: /// the simplifier stops at the error limit and at what the topology allows. - static func level(of mesh: UntoldLODMesh, parts: UntoldLODMeshParts, ratio: Float, errorLimit: Float) -> UntoldLODMeshLevel { + /// `sunkPieces` flags the components of `parts` that the cook sank behind another + /// surface: they are simplified apart from the rest. + static func level(of mesh: UntoldLODMesh, parts: UntoldLODMeshParts, ratio: Float, errorLimit: Float, sunkPieces: [Bool] = []) -> UntoldLODMeshLevel { var positions = mesh.positions var movedVertices: [Int] = [] var error: Float = 0 - var indices = simplifyBody(mesh: mesh, parts: parts, ratio: ratio, errorLimit: errorLimit, error: &error) + var indices = simplifyBody(mesh: mesh, parts: parts, ratio: ratio, errorLimit: errorLimit, sunkPieces: sunkPieces, error: &error) if !parts.clutterIndices.isEmpty { indices += simplifyClutter( mesh: mesh, @@ -298,10 +300,42 @@ enum UntoldMeshLODSimplifier { parts: UntoldLODMeshParts, ratio: Float, errorLimit: Float, + sunkPieces: [Bool], error: inout Float ) -> [UInt32] { let source = parts.bodyIndices guard !source.isEmpty else { return [] } + // A piece the cook sank is bent by its sinking and gives up its triangles less + // readily than the surfaces beside it; simplified with them towards one target it + // would keep its own and take theirs. It is simplified on its own share. + if sunkPieces.contains(true) { + var sunk: [UInt32] = [] + var kept: [UInt32] = [] + for triangle in stride(from: 0, to: source.count, by: 3) { + let component = Int(parts.componentOfVertex[Int(source[triangle])]) + if component >= 0, component < sunkPieces.count, sunkPieces[component] { + sunk += source[triangle ..< triangle + 3] + } else { + kept += source[triangle ..< triangle + 3] + } + } + if !sunk.isEmpty, !kept.isEmpty { + return simplifyBody(indices: sunk, mesh: mesh, parts: parts, ratio: ratio, errorLimit: errorLimit, error: &error) + + simplifyBody(indices: kept, mesh: mesh, parts: parts, ratio: ratio, errorLimit: errorLimit, error: &error) + } + } + return simplifyBody(indices: source, mesh: mesh, parts: parts, ratio: ratio, errorLimit: errorLimit, error: &error) + } + + /// Reduces the triangles `source` of `mesh` towards `ratio` of them. + private static func simplifyBody( + indices source: [UInt32], + mesh: UntoldLODMesh, + parts: UntoldLODMeshParts, + ratio: Float, + errorLimit: Float, + error: inout Float + ) -> [UInt32] { let target = targetIndexCount(source.count, ratio: ratio) guard target < source.count else { return source } @@ -503,6 +537,74 @@ enum UntoldMeshLODSimplifier { return destination } + /// The cosine of the angle from which a kept vertex's normal is taken to belong to the + /// surface it was simplified away from, not to the one it now sits on: 30 degrees. + static let settledNormalCosine: Float = 0.866 + + /// Gives every vertex of a level whose normal is far from the faces around it the + /// normal of those faces. The simplifier keeps the vertices it does not collapse with + /// their normals as they were: a vertex that stood in a groove, on a crease or at the + /// foot of a ridge keeps a sideways normal when the groove is gone and it sits on a + /// span of flat triangles, and that shades the span dark. The faces are averaged per + /// vertex, not per position, so a hard edge (two vertices at one position, a normal + /// for each side) keeps both its normals; and the average is turned to the side of + /// the vertex's own normal, since a mesh wound the other way round (a mirrored part) + /// has its faces' cross products pointing in. The tangent follows the normal it is + /// paired with. Returns how many normals were replaced. + @discardableResult + static func settleNormals(of vertices: inout Data, vertexCount: Int, indices: [UInt32]) -> Int { + guard vertexCount > 0, !indices.isEmpty else { return 0 } + var faceSum = [SIMD3](repeating: .zero, count: vertexCount) + var replaced = 0 + vertices.withUnsafeMutableBytes { raw in + func position(_ vertex: Int) -> SIMD3 { + let record = vertex * UntoldLODMesh.vertexStride + return SIMD3( + Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record, as: UInt32.self))), + Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record + 4, as: UInt32.self))), + Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record + 8, as: UInt32.self))) + ) + } + for triangle in stride(from: 0, to: indices.count, by: 3) { + let a = Int(indices[triangle]), b = Int(indices[triangle + 1]), c = Int(indices[triangle + 2]) + guard a < vertexCount, b < vertexCount, c < vertexCount else { continue } + let pa = position(a) + // Twice the area, along the face's normal: the larger faces weigh more. + let weighted = simd_cross(position(b) - pa, position(c) - pa) + faceSum[a] += weighted + faceSum[b] += weighted + faceSum[c] += weighted + } + for vertex in 0 ..< vertexCount { + let length = simd_length(faceSum[vertex]) + guard length > 0 else { continue } + var faces = faceSum[vertex] / length + let record = vertex * UntoldLODMesh.vertexStride + let packedNormal = UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record + 12, as: UInt32.self)) + let normal = UntoldVertexPacking.unpackNormal(packedNormal) + if simd_dot(normal, faces) < 0 { + faces = -faces + } + guard simd_dot(normal, faces) < settledNormalCosine else { continue } + raw.storeBytes(of: UntoldVertexPacking.packNormal(faces).littleEndian, toByteOffset: record + 12, as: UInt32.self) + // The tangent stays in the surface: its part along the new normal goes. + let packedTangent = UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: record + 16, as: UInt32.self)) + let tangent = UntoldVertexPacking.unpackTangent(packedTangent) + let inPlane = tangent.vector - faces * simd_dot(tangent.vector, faces) + let inPlaneLength = simd_length(inPlane) + if inPlaneLength > 1e-4 { + raw.storeBytes( + of: UntoldVertexPacking.packTangent(inPlane / inPlaneLength, handedness: tangent.handedness).littleEndian, + toByteOffset: record + 16, + as: UInt32.self + ) + } + replaced += 1 + } + } + return replaced + } + /// Orders the triangles for the GPU's vertex cache and keeps only the vertices they use. private static func compact( mesh: UntoldLODMesh, @@ -544,6 +646,7 @@ enum UntoldMeshLODSimplifier { } } vertices.removeSubrange(vertexCount * UntoldLODMesh.vertexStride ..< vertices.count) + settleNormals(of: &vertices, vertexCount: vertexCount, indices: ordered) var lower = SIMD3(repeating: .greatestFiniteMagnitude) var upper = SIMD3(repeating: -.greatestFiniteMagnitude) diff --git a/Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift b/Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift index cc48e3be6..31699e5ac 100644 --- a/Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift +++ b/Tests/UntoldEngineTests/UntoldMeshLODCookerTests.swift @@ -395,6 +395,514 @@ final class UntoldMeshLODCookerTests: XCTestCase { // MARK: - Packs + /// The meshes of a model as the cooker sees them, with their transforms and the + /// model's diameter. + private func unpacked(_ model: LODTestModel) throws -> ([UntoldLODMesh], [simd_float4x4], Float) { + let url = try model.write(to: directory, name: "unpacked-\(UUID().uuidString)") + return try UntoldMeshLODCooker.unpackedMeshes(ofModelAt: url) + } + + // MARK: - Parts hidden behind another + + /// Whether `point` lies inside the closed surface `positions`/`indices`: a ray in a + /// direction no edge of a lat-long sphere runs along crosses it an odd number of times. + private func isInside(_ point: SIMD3, positions: [SIMD3], indices: [UInt32]) -> Bool { + crossings(from: point, direction: simd_normalize(SIMD3(0.53, 0.71, 0.46)), positions: positions, indices: indices) % 2 == 1 + } + + /// How many triangles of `positions`/`indices` a ray from `point` along `direction` crosses. + private func crossings(from point: SIMD3, direction: SIMD3, positions: [SIMD3], indices: [UInt32]) -> Int { + var crossings = 0 + for triangle in stride(from: 0, to: indices.count, by: 3) { + let a = positions[Int(indices[triangle])], b = positions[Int(indices[triangle + 1])], c = positions[Int(indices[triangle + 2])] + let e1 = b - a, e2 = c - a + let p = simd_cross(direction, e2) + let det = simd_dot(e1, p) + if abs(det) < 1e-9 { + continue + } + let f = 1 / det + let s = point - a + let u = f * simd_dot(s, p) + if u < 0 || u > 1 { + continue + } + let q = simd_cross(s, e1) + let v = f * simd_dot(direction, q) + if v < 0 || u + v > 1 { + continue + } + if f * simd_dot(e2, q) > 1e-6 { + crossings += 1 + } + } + return crossings + } + + /// The hidden parts of a model as the cook finds them for the default ratios. + private func hiddenParts(_ meshes: [UntoldLODMesh], _ transforms: [simd_float4x4], diameter: Float, glass: [Int] = [], ratios: [Float] = [0.5, 0.15, 0.03]) -> UntoldMeshLODHiddenParts { + let triangles = meshes.reduce(0) { $0 + $1.triangleCount } + return UntoldMeshLODHiddenParts( + meshes: meshes, parts: meshes.map(UntoldLODMeshParts.init), transforms: transforms, + glass: meshes.indices.map { glass.contains($0) }, diameter: diameter, + coarsestAllowance: diameter / (Float(triangles) * ratios.min()!).squareRoot() + ) + } + + private func distinctVertexCount(_ mesh: UntoldLODMesh) -> Int { + (0 ..< mesh.vertexCount).filter { Int(mesh.positionRemap[$0]) == $0 }.count + } + + func testAShellHiddenInAnotherStaysInsideItAtEveryLevel() throws { + // A shell a hundredth of the model's size inside another, both curved tightly: a + // coarse level's triangles cut inside the curve by more than the gap (a sphere + // of 0.2 split into 70 triangles sags a hundredth), and the simplifier keeps + // vertices on the surface, so the outer shell's faces fall through the inner + // one's vertices unless the inner one is sunk first. + let outer = LODTestMesh.sphere(radius: 0.2, rings: 20, segments: 40, material: 0) + let inner = LODTestMesh.sphere(radius: 0.196, rings: 20, segments: 40, material: 1) + let url = try LODTestModel(meshes: [outer, inner]).write(to: directory, name: "shells") + // The control: the same model with the inner shell out of reach, at the centre. + // The allowances are the model's (its size and its triangles), so the outer shell + // simplifies the same unless something touches it. + let core = LODTestMesh.sphere(radius: 0.05, rings: 20, segments: 40, material: 1) + let aloneURL = try LODTestModel(meshes: [outer, core]).write(to: directory, name: "shell-alone") + let options = UntoldMeshLODOptions(minimumTriangles: 0) + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: url, options: options) + let alone = try UntoldMeshLODCooker.cookChain(forModelAt: aloneURL, options: options) + XCTAssertEqual(chain.levels.count, 3, "the hidden shell costs no level") + XCTAssertEqual(alone.levels.count, 3) + for (level, aloneLevel) in zip(chain.levels, alone.levels) { + let outerLevel = try levelGeometry(level.url, mesh: 0) + let innerLevel = try levelGeometry(level.url, mesh: 1) + // Every fifth vertex: the check is a ray against every outer triangle. + let sampled = stride(from: 0, to: innerLevel.positions.count, by: 5).map { innerLevel.positions[$0] } + let outside = sampled.filter { !isInside($0, positions: outerLevel.positions, indices: outerLevel.indices) } + XCTAssertTrue(outside.isEmpty, "\(outside.count) of \(sampled.count) inner vertices come through the outer shell at \(level.url.lastPathComponent)") + // The seen shell is the plain simplification: what it is on its own, bit for bit. + let aloneGeometry = try levelGeometry(aloneLevel.url, mesh: 0) + XCTAssertEqual(outerLevel.positions, aloneGeometry.positions, "the outer shell is not touched at \(level.url.lastPathComponent)") + XCTAssertEqual(outerLevel.indices, aloneGeometry.indices) + } + } + + func testAHiddenVertexIsSunkTwiceTheAllowanceBehindTheFaceInFrontOfIt() throws { + // The same tessellation for both: the ray from every inner vertex passes through + // an outer vertex, and must still count as covered. + let outer = LODTestMesh.sphere(radius: 0.2, rings: 40, segments: 80) + let inner = LODTestMesh.sphere(radius: 0.196, rings: 40, segments: 80, material: 1) + let (meshes, transforms, diameter) = try unpacked(LODTestModel(meshes: [outer, inner])) + let parts = hiddenParts(meshes, transforms, diameter: diameter) + XCTAssertTrue(parts.hiddenVertices[0].isEmpty, "the outer shell is seen") + XCTAssertEqual(parts.hiddenVertices[1].count, distinctVertexCount(meshes[1]), "every point of the inner shell is hidden") + for hidden in parts.hiddenVertices[1] { + XCTAssertEqual(hidden.references.count, 1, "one face in front of it: \(hidden.references) at \(meshes[1].position(hidden.vertex))") + XCTAssertEqual(hidden.references[0].distance, 0.004, accuracy: 0.0005) + } + + // At an allowance of 0.01 the shell, 0.004 behind, ends 0.02 + 0.004 / 3 behind, + // less the little that averaging the sinks over a surface this tightly curved + // (4.5 degrees between neighbours) takes off them. + let sunk = parts.sunk(meshes, allowance: 0.01) + XCTAssertEqual(sunk[0].positions, meshes[0].positions, "the outer shell does not move") + XCTAssertEqual(sunk[0].vertexData, meshes[0].vertexData) + for vertex in 0 ..< sunk[1].vertexCount { + XCTAssertEqual(simd_length(sunk[1].position(vertex)), 0.2 - (0.02 + 0.004 / 3), accuracy: 0.002, "vertex \(vertex)") + } + // The record the level is written from moves with it. + let fromData = UntoldLODMesh(vertexData: sunk[1].vertexData, indices: sunk[1].indices) + XCTAssertEqual(fromData.positions, sunk[1].positions) + XCTAssertTrue(all(sunk[1].boundsMax .<= meshes[1].boundsMax + 1e-6) && all(sunk[1].boundsMin .>= meshes[1].boundsMin - 1e-6)) + + // Out of reach of a small allowance (three times 0.001 is under the gap): untouched. + let still = parts.sunk(meshes, allowance: 0.001) + XCTAssertEqual(still[1].positions, meshes[1].positions) + XCTAssertEqual(still[1].vertexData, meshes[1].vertexData) + } + + func testAPartRestingOnAnotherMovesNeither() throws { + // A ball on a floor: each is in front of the other, and the floor under the + // ball, though covered, is not behind the ball's faces. + let floor = LODTestMesh.groovedSurface(cells: 60, grooveDepth: 0) + let ball = LODTestMesh.sphere(radius: 0.5, center: SIMD3(0, 0.5, 0), rings: 30, segments: 60, material: 1) + let (meshes, transforms, diameter) = try unpacked(LODTestModel(meshes: [floor, ball])) + let parts = UntoldMeshLODHiddenParts( + meshes: meshes, parts: meshes.map(UntoldLODMeshParts.init), transforms: transforms, glass: [false, false], diameter: diameter, coarsestAllowance: 0.1 + ) + XCTAssertTrue(parts.isEmpty, "\(parts.count) vertices taken for hidden") + } + + func testOnlyAPartWhoseOwnSideIsCoveredIsSunk() throws { + // A sheet a short way under a larger one. Facing up, its side is covered by the + // upper sheet and it is hidden; facing down, with nothing below, it is seen. + let upper = LODTestMesh.wavySurface(cells: 40, material: 0) + let lower = LODTestMesh.wavySurface(cells: 36, origin: SIMD2(-4.5, -4.5), size: SIMD2(9, 9), offset: SIMD3(0, -0.1, 0), material: 1) + var flipped = lower + flipped.normals = flipped.normals.map { -$0 } + for triangle in stride(from: 0, to: flipped.indices.count, by: 3) { + flipped.indices.swapAt(triangle + 1, triangle + 2) + } + let (meshes, transforms, diameter) = try unpacked(LODTestModel(meshes: [upper, lower])) + let covered = hiddenParts(meshes, transforms, diameter: diameter) + XCTAssertTrue(covered.hiddenVertices[0].isEmpty) + XCTAssertEqual(covered.hiddenVertices[1].count, distinctVertexCount(meshes[1]), "the whole lower sheet is hidden") + for hidden in covered.hiddenVertices[1] { + XCTAssertEqual(hidden.references[0].distance, 0.1, accuracy: 0.02) + XCTAssertGreaterThan(hidden.references[0].normal.y, 0.8, "sunk away from the upper sheet, downwards") + } + let (seenMeshes, seenTransforms, seenDiameter) = try unpacked(LODTestModel(meshes: [upper, flipped])) + let seen = hiddenParts(seenMeshes, seenTransforms, diameter: seenDiameter) + XCTAssertTrue(seen.isEmpty, "\(seen.count) vertices of a sheet that faces the open taken for hidden") + } + + func testAHiddenPartInACornerMovesAwayFromEveryFaceOfIt() throws { + let outer = LODTestMesh.box(size: 2, cells: 20, material: 0) + let inner = LODTestMesh.box(size: 1.9, cells: 10, material: 1) + let (meshes, transforms, diameter) = try unpacked(LODTestModel(meshes: [outer, inner])) + let parts = UntoldMeshLODHiddenParts( + meshes: meshes, parts: meshes.map(UntoldLODMeshParts.init), transforms: transforms, glass: [false, false], diameter: diameter, coarsestAllowance: 0.03 + ) + XCTAssertTrue(parts.hiddenVertices[0].isEmpty) + XCTAssertEqual(parts.hiddenVertices[1].count, distinctVertexCount(meshes[1])) + // Reach 0.09, the faces 0.05 away: each moves the vertex (0.09 - 0.05) * (0.06 / 0.09). + // A corner's three sinks are averaged with its neighbours', which have one or + // two: it moves away from all three faces, by less than the full amount each. + let sunk = parts.sunk(meshes, allowance: 0.03) + let moved: Float = 0.95 - (0.09 - 0.05) * (0.06 / 0.09) + var corners = 0 + var faceCentres = 0 + for vertex in 0 ..< meshes[1].vertexCount { + let before = meshes[1].position(vertex) + let after = sunk[1].position(vertex) + if all(simd_abs(before) .== SIMD3(repeating: 0.95)) { + corners += 1 + XCTAssertLessThan(simd_reduce_max(simd_abs(after)), 0.95 - 0.005, "a corner moves away from its three faces: \(after)") + XCTAssertGreaterThan(simd_reduce_min(simd_abs(after)), moved - 0.005, "and no more than the full amount from any: \(after)") + } else if simd_reduce_max(simd_abs(before)) == 0.95, (0 ..< 3).filter({ before[$0] == 0 }).count == 2 { + faceCentres += 1 + XCTAssertEqual(simd_reduce_max(simd_abs(after)), moved, accuracy: 1e-3, "a face's middle moves away from its one face") + XCTAssertEqual(simd_reduce_min(simd_abs(after)), 0, accuracy: 1e-3, "and stays in its face's plane") + } + } + XCTAssertEqual(corners, 8 * 3, "each corner has a vertex on each of its faces") + XCTAssertEqual(faceCentres, 6) + } + + func testAVertexTwoHiddenMeshesShareIsSunkTheSameWayInBoth() throws { + // A roof over a lining made of two meshes that share their border, bit for bit. + let roof = LODTestMesh.wavySurface(cells: 40, material: 0) + let left = LODTestMesh.wavySurface(cells: 18, origin: SIMD2(-4.5, -4.5), size: SIMD2(4.5, 9), offset: SIMD3(0, -0.1, 0), material: 1) + let right = LODTestMesh.wavySurface(cells: 18, origin: SIMD2(0, -4.5), size: SIMD2(4.5, 9), offset: SIMD3(0, -0.1, 0), material: 2) + let (meshes, transforms, diameter) = try unpacked(LODTestModel(meshes: [roof, left, right])) + let lock: UInt8 = 1 // meshopt_SimplifyVertex_Lock + let border = (0 ..< meshes[1].vertexCount).filter { meshes[1].lockFlags[$0] & lock != 0 } + XCTAssertEqual(border.count, 19, "the shared border is locked") + let parts = hiddenParts(meshes, transforms, diameter: diameter) + let sunk = parts.sunk(meshes, allowance: 0.05) + var moved = 0 + for vertex in border { + let position = sunk[1].position(vertex) + XCTAssertNotEqual(position, meshes[1].position(vertex), "a border vertex of a hidden lining is sunk") + let twin = (0 ..< meshes[2].vertexCount).first { meshes[2].position($0) == meshes[1].position(vertex) } + let twinPosition = try sunk[2].position(XCTUnwrap(twin, "the border is shared")) + XCTAssertEqual(twinPosition, position, "the two halves keep their border closed") + moved += 1 + } + XCTAssertEqual(moved, 19) + } + + func testAHiddenSheetUnderARoofStaysUnderItAtEveryLevel() throws { + // A headliner: a sheet a millimetre under a curved roof. A coarse level of the + // roof deviates by far more than that, and would cut below the sheet. + let roof = LODTestMesh.wavySurface(cells: 40, material: 0) + let lining = LODTestMesh.wavySurface(cells: 36, origin: SIMD2(-4.5, -4.5), size: SIMD2(9, 9), offset: SIMD3(0, -0.001, 0), material: 1) + let url = try LODTestModel(meshes: [roof, lining]).write(to: directory, name: "roof") + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: url, options: UntoldMeshLODOptions(minimumTriangles: 0)) + XCTAssertEqual(chain.levels.count, 3) + for level in chain.levels { + let roofLevel = try levelGeometry(level.url, mesh: 0) + let liningLevel = try levelGeometry(level.url, mesh: 1) + // Away from the border, where the roof's coarse triangles still cover the sheet. + let inside = liningLevel.positions.filter { abs($0.x) < 4 && abs($0.z) < 4 } + XCTAssertGreaterThan(inside.count, 10) + let above = inside.filter { crossings(from: $0, direction: SIMD3(0, 1, 0), positions: roofLevel.positions, indices: roofLevel.indices) == 0 } + XCTAssertTrue(above.isEmpty, "\(above.count) of \(inside.count) lining vertices come up through the roof at \(level.url.lastPathComponent)") + } + } + + func testAPartBehindGlassIsSeenThroughItAndStays() throws { + // A lining under a glass roof: covered, as far as hiding goes, but nothing is + // sunk away from glass. + let roof = LODTestMesh.wavySurface(cells: 40, material: 0) + let lining = LODTestMesh.wavySurface(cells: 36, origin: SIMD2(-4.5, -4.5), size: SIMD2(9, 9), offset: SIMD3(0, -0.1, 0), material: 1) + let (meshes, transforms, diameter) = try unpacked(LODTestModel(meshes: [roof, lining])) + XCTAssertTrue(hiddenParts(meshes, transforms, diameter: diameter, glass: [0]).isEmpty) + XCTAssertFalse(hiddenParts(meshes, transforms, diameter: diameter).isEmpty, "under a solid roof it is sunk") + // The cook reads the glass off the material: a transmitting one, or one not opaque. + var model = LODTestModel(meshes: [roof, lining]) + model.glassMaterials = [0] + let url = try model.write(to: directory, name: "glass-roof") + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: url, options: UntoldMeshLODOptions(minimumTriangles: 0)) + let plainURL = try LODTestModel(meshes: [lining]).write(to: directory, name: "lining-alone") + _ = plainURL + XCTAssertEqual(chain.levels.count, 3) + let liningLevel = try levelGeometry(chain.levels[2].url, mesh: 1) + XCTAssertTrue(liningLevel.positions.allSatisfy { $0.y > -0.1 - 0.4 - 1e-3 }, "the lining keeps its place under the glass") + } + + func testAHiddenPartBetweenTwoSeenSurfacesStaysBetweenThem() throws { + // A lining 0.1 under a roof, with a floor 0.15 under the lining that faces the + // open below: the lining is behind both. At an allowance of 0.07 the roof would + // sink it 0.073 and the floor lift it 0.04; it moves the difference, 0.033, and + // never nearer the floor than two allowances: where the floor is straight below + // that holds it to 0.01, where the two faces slant the move it has the room. + let roof = LODTestMesh.wavySurface(cells: 40, material: 0) + let lining = LODTestMesh.wavySurface(cells: 36, origin: SIMD2(-4.5, -4.5), size: SIMD2(9, 9), offset: SIMD3(0, -0.1, 0), material: 1) + var floor = LODTestMesh.wavySurface(cells: 40, offset: SIMD3(0, -0.25, 0), material: 2) + floor.normals = floor.normals.map { -$0 } + for triangle in stride(from: 0, to: floor.indices.count, by: 3) { + floor.indices.swapAt(triangle + 1, triangle + 2) + } + let (meshes, transforms, diameter) = try unpacked(LODTestModel(meshes: [roof, lining, floor])) + let parts = hiddenParts(meshes, transforms, diameter: diameter) + XCTAssertEqual(parts.hiddenVertices[1].count, distinctVertexCount(meshes[1])) + XCTAssertTrue(parts.hiddenVertices[2].isEmpty, "the floor faces the open below") + for hidden in parts.hiddenVertices[1] { + XCTAssertEqual(hidden.references.count, 2, "the roof and the floor") + } + let grid = try XCTUnwrap(parts.grid) + let blocked = parts.sunk(meshes, allowance: 0.07) + var moves: [Float] = [] + for vertex in 0 ..< meshes[1].vertexCount { + let before = simd_make_float3(simd_mul(transforms[1], SIMD4(meshes[1].position(vertex), 1))) + let after = simd_make_float3(simd_mul(transforms[1], SIMD4(blocked[1].position(vertex), 1))) + let move = simd_length(after - before) + moves.append(move) + // The two faces slant apart where the waves differ, so the sinks do not cancel + // exactly: up to a fifth more than their difference. + XCTAssertLessThanOrEqual(move, 0.034 * 1.25, "vertex \(vertex): about the difference of the two sinks") + guard move > 0 else { continue } + let room = grid.nearestHit(from: after, direction: (after - before) / move, within: 1, ofTriangles: parts.referenceTriangles) ?? 1 + XCTAssertGreaterThanOrEqual(room, 0.14 - 0.002, "vertex \(vertex): two allowances short of the floor") + } + XCTAssertLessThan(try XCTUnwrap(moves.min()), 0.02, "held to 0.01 where the floor is straight below") + XCTAssertGreaterThan(try XCTUnwrap(moves.max()), 0.0105, "a little more where the slant gives it room") + // At an allowance of 0.05 the roof sinks it 0.033 and the floor, at the edge of + // reach, not at all; two allowances leave 0.05 of room, enough everywhere. + let free = parts.sunk(meshes, allowance: 0.05) + for vertex in 0 ..< meshes[1].vertexCount { + XCTAssertEqual(simd_length(free[1].position(vertex) - meshes[1].position(vertex)), 0.033, accuracy: 0.004, "vertex \(vertex)") + } + } + + func testALiningHalfUnderARoofSinksWhereItIsCoveredAndBendsToWhereItIsNot() throws { + // A lining 0.1 under a roof that covers only its half with x < 0: the covered + // half sinks, the open half is seen and stays, and between them the sinks taper + // off over a few rings of vertices instead of stepping. + let roof = LODTestMesh.wavySurface(cells: 40, origin: SIMD2(-10, -5), size: SIMD2(10, 10), material: 0) + let lining = LODTestMesh.wavySurface(cells: 72, origin: SIMD2(-9, -4.5), size: SIMD2(18, 9), offset: SIMD3(0, -0.1, 0), material: 1) + let (meshes, transforms, diameter) = try unpacked(LODTestModel(meshes: [roof, lining])) + let parts = hiddenParts(meshes, transforms, diameter: diameter) + let hiddenX = parts.hiddenVertices[1].map { meshes[1].position($0.vertex).x } + XCTAssertGreaterThan(hiddenX.count, 1000) + XCTAssertLessThan(try XCTUnwrap(hiddenX.max()), 0.3, "only the covered half is hidden") + XCTAssertFalse(parts.hiddenPieces[1][0], "half a piece is not a sunk piece") + let sunk = parts.sunk(meshes, allowance: 0.1) + var byColumn: [Float: Float] = [:] + for vertex in 0 ..< meshes[1].vertexCount where abs(meshes[1].position(vertex).z) < 0.3 { + let x = (meshes[1].position(vertex).x * 4).rounded() / 4 + byColumn[x] = max(byColumn[x] ?? 0, simd_length(sunk[1].position(vertex) - meshes[1].position(vertex))) + } + let columns = byColumn.keys.sorted() + XCTAssertGreaterThan(byColumn[-5] ?? 0, 0.1, "well under the roof the lining sinks") + XCTAssertEqual(byColumn[5] ?? 1, 0, "in the open it stays") + // Along the middle, no column's move differs from the next's by more than half + // the full sink: the taper from the covered half to the open one spans several + // columns, with the largest step where the first covered vertices meet the seen + // ones, which are held still. + let full = byColumn[-5] ?? 0 + for (left, right) in zip(columns, columns.dropFirst()) { + XCTAssertLessThan(abs((byColumn[left] ?? 0) - (byColumn[right] ?? 0)), full / 2, "a step at x \(right)") + } + } + + func testASunkPieceDoesNotTakeTheTrianglesOfThePiecesBesideIt() throws { + // One mesh of two pieces: a lining under a roof and a sheet in the open beside + // it. The control is the same model with the roof made of glass, where nothing + // is sunk. Each piece keeps about the triangles it has in the control: the sunk + // one does not hold on to its own (its sinks are smoothed) nor take the sheet's. + let roof = LODTestMesh.wavySurface(cells: 40, material: 0) + let lining = LODTestMesh.wavySurface(cells: 36, origin: SIMD2(-4.5, -4.5), size: SIMD2(9, 9), offset: SIMD3(0, -0.1, 0), material: 1) + let sheet = LODTestMesh.wavySurface(cells: 40, origin: SIMD2(6, -5), size: SIMD2(10, 10), material: 1) + func cook(glassRoof: Bool) throws -> (lining: Int, sheet: Int) { + var model = LODTestModel(meshes: [roof, LODTestMesh.merged([lining, sheet])]) + if glassRoof { + model.glassMaterials = [0] + } + let url = try model.write(to: directory, name: glassRoof ? "beside-control" : "beside") + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: url, options: UntoldMeshLODOptions(minimumTriangles: 0)) + XCTAssertEqual(chain.levels.count, 3) + let level = try levelGeometry(chain.levels[2].url, mesh: 1) + var liningTriangles = 0 + var sheetTriangles = 0 + for triangle in stride(from: 0, to: level.indices.count, by: 3) { + if level.positions[Int(level.indices[triangle])].x < 5.5 { + liningTriangles += 1 + } else { + sheetTriangles += 1 + } + } + return (liningTriangles, sheetTriangles) + } + let sunk = try cook(glassRoof: false) + let control = try cook(glassRoof: true) + XCTAssertGreaterThan(control.lining, 20) + XCTAssertGreaterThan(control.sheet, 20) + XCTAssertEqual(Float(sunk.sheet), Float(control.sheet), accuracy: Float(control.sheet) * 0.15, "the sheet keeps its share: \(sunk) against \(control)") + XCTAssertLessThan(Float(sunk.lining), Float(control.lining) * 1.5, "the sunk lining collapses about as readily: \(sunk) against \(control)") + } + + func testASunkPieceThatCannotCollapseLeavesThePiecesBesideItTheirTriangles() throws { + // A lining so rough that no collapse of it is within the allowance, hidden between + // a roof and a floor that faces the open below, in one mesh with a smooth sheet + // in the open. Simplified towards one target with the lining, the sheet would be + // collapsed to what the allowance leaves of it; on its own share it keeps about + // the share the ratio asks for. + // The roof and the floor reach well past the lining, so that no ray from its + // bumps slips out past their edges. + let roof = LODTestMesh.wavySurface(cells: 40, origin: SIMD2(-10, -10), size: SIMD2(20, 20), material: 0) + var lining = LODTestMesh.wavySurface(cells: 36, origin: SIMD2(-4.5, -4.5), size: SIMD2(9, 9), offset: SIMD3(0, -0.5, 0), material: 1) + var random = SplitMix64(state: 7) + for index in lining.positions.indices { + let x = lining.positions[index].x, z = lining.positions[index].z + guard abs(x) < 4.4, abs(z) < 4.4 else { continue } + lining.positions[index].y -= Float(random.next() % 1000) / 1000 + } + let sheet = LODTestMesh.wavySurface(cells: 40, origin: SIMD2(12, -5), size: SIMD2(10, 10), material: 1) + var floor = LODTestMesh.wavySurface(cells: 40, origin: SIMD2(-10, -10), size: SIMD2(20, 20), offset: SIMD3(0, -2, 0), material: 2) + floor.normals = floor.normals.map { -$0 } + for triangle in stride(from: 0, to: floor.indices.count, by: 3) { + floor.indices.swapAt(triangle + 1, triangle + 2) + } + let url = try LODTestModel(meshes: [roof, LODTestMesh.merged([lining, sheet]), floor]).write(to: directory, name: "rough-beside") + let (meshes, transforms, diameter) = try UntoldMeshLODCooker.unpackedMeshes(ofModelAt: url) + let parts = hiddenParts(meshes, transforms, diameter: diameter) + XCTAssertEqual(parts.hiddenPieces[1].filter { $0 }.count, 1, "the lining is hidden, the sheet is not") + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: url, options: UntoldMeshLODOptions(minimumTriangles: 0)) + XCTAssertEqual(chain.levels.count, 3) + let level = try levelGeometry(chain.levels[2].url, mesh: 1) + var sheetTriangles = 0 + for triangle in stride(from: 0, to: level.indices.count, by: 3) where level.positions[Int(level.indices[triangle])].x > 11.5 { + sheetTriangles += 1 + } + let asked = Float(sheet.indices.count / 3) * 0.03 + XCTAssertGreaterThan(Float(sheetTriangles), asked * 0.6, "the sheet keeps about its share of \(asked)") + } + + func testTheFacesAPointIsBehindAndTheRaysThatEscape() throws { + let shell = LODTestMesh.sphere(radius: 1, rings: 40, segments: 80) + let (meshes, transforms, _) = try unpacked(LODTestModel(meshes: [shell, LODTestMesh.sphere(radius: 0.1, rings: 10, segments: 20, material: 1)])) + let grid = UntoldMeshLODHiddenParts.ModelGrid(meshes: meshes, transforms: transforms, included: [0, 1], cellSize: 0.1) + func found(_ point: SIMD3, ofMeshes: [Bool] = [true, false]) -> [UntoldMeshLODHiddenParts.Reference] { + let triangles = (0 ..< grid.triangleCount).map { ofMeshes[grid.mesh(ofTriangle: $0)] } + return grid.references(near: point, within: 0.3, touching: 1e-5, behindCosine: 0.7, ofTriangles: triangles, limit: 3, distinctCosine: 0.5) + } + let inside = found(SIMD3(0.9, 0, 0)) + XCTAssertEqual(inside.count, 1) + XCTAssertEqual(inside.first?.distance ?? 0, 0.1, accuracy: 0.01) + XCTAssertEqual(inside.first?.normal.x ?? 0, 1, accuracy: 0.05, "the face there points along x") + XCTAssertTrue(found(SIMD3(1.1, 0, 0)).isEmpty, "in front of the face") + XCTAssertTrue(found(SIMD3(0.5, 0, 0)).isEmpty, "farther than the reach") + XCTAssertTrue(found(SIMD3(0.9, 0, 0), ofMeshes: [false, false]).isEmpty, "the point's own meshes do not count") + XCTAssertTrue(grid.rayHitsAnything(from: SIMD3(0.5, 0, 0), direction: SIMD3(1, 0, 0)), "out through the shell") + XCTAssertTrue(grid.rayHitsAnything(from: SIMD3(0.5, 0, 0), direction: SIMD3(-1, 0, 0)), "through the small sphere and the shell") + XCTAssertFalse(grid.rayHitsAnything(from: SIMD3(1.2, 0, 0), direction: SIMD3(1, 0, 0)), "away from everything") + XCTAssertTrue(grid.rayHitsAnything(from: SIMD3(3, 0, 0), direction: SIMD3(-1, 0, 0)), "into the grid from outside") + let shellOnly = (0 ..< grid.triangleCount).map { grid.mesh(ofTriangle: $0) == 0 } + XCTAssertEqual(try XCTUnwrap(grid.nearestHit(from: SIMD3(0.5, 0, 0), direction: SIMD3(1, 0, 0), within: 1, ofTriangles: shellOnly)), 0.5, accuracy: 0.01) + XCTAssertEqual(try XCTUnwrap(grid.nearestHit(from: SIMD3(0.5, 0, 0), direction: SIMD3(-1, 0, 0), within: 2, ofTriangles: shellOnly)), 1.5, accuracy: 0.01, "through the small sphere, which does not count") + XCTAssertNil(grid.nearestHit(from: SIMD3(0.5, 0, 0), direction: SIMD3(1, 0, 0), within: 0.3, ofTriangles: shellOnly), "farther than the limit") + } + + // MARK: - The normals of a level + + /// For each vertex of a level, the angle in degrees between its normal and the + /// area-weighted normal of the faces around it, taken on the side of the vertex's + /// normal: a triangle the simplifier folds over has its cross product pointing the + /// other way, and the normal is not asked to follow it. + private func normalAngles(_ url: URL) throws -> [Float] { + let (meshes, _, _) = try UntoldMeshLODCooker.unpackedMeshes(ofModelAt: url) + var angles: [Float] = [] + for mesh in meshes { + var faceSum = [SIMD3](repeating: .zero, count: mesh.vertexCount) + for triangle in stride(from: 0, to: mesh.indices.count, by: 3) { + let a = Int(mesh.indices[triangle]), b = Int(mesh.indices[triangle + 1]), c = Int(mesh.indices[triangle + 2]) + let weighted = simd_cross(mesh.position(b) - mesh.position(a), mesh.position(c) - mesh.position(a)) + faceSum[a] += weighted + faceSum[b] += weighted + faceSum[c] += weighted + } + for vertex in 0 ..< mesh.vertexCount where simd_length(faceSum[vertex]) > 0 { + let normal = SIMD3(mesh.normals[vertex * 3], mesh.normals[vertex * 3 + 1], mesh.normals[vertex * 3 + 2]) + let cosine = abs(simd_dot(normal, simd_normalize(faceSum[vertex]))) + angles.append(acos(max(-1, min(1, cosine))) * 180 / .pi) + } + } + return angles + } + + func testAVertexKeptFromAGrooveTakesTheNormalOfTheFacesAroundIt() throws { + // A flat surface with a narrow groove: at the coarse levels the groove is gone, + // and the vertices kept from its walls sit on flat triangles. + let url = try LODTestModel(meshes: [LODTestMesh.groovedSurface(cells: 120)]).write(to: directory, name: "groove") + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: url, options: UntoldMeshLODOptions(minimumTriangles: 0)) + XCTAssertEqual(chain.levels.count, 3) + for level in chain.levels { + let angles = try normalAngles(level.url) + XCTAssertFalse(angles.isEmpty) + XCTAssertLessThan(angles.max() ?? 0, 30, "every normal of \(level.url.lastPathComponent) belongs to the faces around it") + } + // The surface's own normals are not touched: away from the groove they are the + // ones the model has, bit for bit. + let (meshes, _, _) = try UntoldMeshLODCooker.unpackedMeshes(ofModelAt: chain.levels[0].url) + let flat = (0 ..< meshes[0].vertexCount).filter { abs(meshes[0].position($0).x) > 1 } + XCTAssertFalse(flat.isEmpty) + for vertex in flat { + XCTAssertEqual(meshes[0].normals[vertex * 3 + 1], UntoldVertexPacking.unpackNormal(UntoldVertexPacking.packNormal(SIMD3(0, 1, 0))).y) + } + } + + func testAMeshWoundTheOtherWayKeepsItsNormalsPointingOut() throws { + // The test sphere's triangles are wound clockwise seen from outside, so their + // cross products point in, as a mirrored part's do; its normals point out, and + // must still at every level. + let url = try LODTestModel(meshes: [LODTestMesh.sphere(radius: 1, rings: 60, segments: 120)]).write(to: directory, name: "wound") + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: url, options: UntoldMeshLODOptions(minimumTriangles: 0)) + XCTAssertEqual(chain.levels.count, 3) + for level in chain.levels { + let (meshes, _, _) = try UntoldMeshLODCooker.unpackedMeshes(ofModelAt: level.url) + for vertex in 0 ..< meshes[0].vertexCount { + let normal = SIMD3(meshes[0].normals[vertex * 3], meshes[0].normals[vertex * 3 + 1], meshes[0].normals[vertex * 3 + 2]) + // Out, if not along the radius: a vertex the collapses leave with a + // lopsided set of faces takes those faces' tilt. + XCTAssertGreaterThan(simd_dot(normal, simd_normalize(meshes[0].position(vertex))), 0.5, "vertex \(vertex) of \(level.url.lastPathComponent) points out") + } + } + } + + func testTheHardEdgesOfABoxKeepTheirNormalsAtEveryLevel() throws { + let url = try LODTestModel(meshes: [LODTestMesh.box()]).write(to: directory, name: "box") + let chain = try UntoldMeshLODCooker.cookChain(forModelAt: url, options: UntoldMeshLODOptions(minimumTriangles: 0)) + XCTAssertFalse(chain.levels.isEmpty) + for level in chain.levels { + let (meshes, _, _) = try UntoldMeshLODCooker.unpackedMeshes(ofModelAt: level.url) + for vertex in 0 ..< meshes[0].vertexCount { + let normal = SIMD3(meshes[0].normals[vertex * 3], meshes[0].normals[vertex * 3 + 1], meshes[0].normals[vertex * 3 + 2]) + XCTAssertGreaterThan(simd_reduce_max(simd_abs(normal)), 0.99, "a face's normal, not a blend across the edge") + } + } + } + func testAPackGetsAChainForEachModelThatNeedsOne() throws { let packURL = try writePack( models: [ @@ -773,6 +1281,101 @@ struct LODTestMesh { return mesh } + /// A sphere of `radius` about `center`, `rings` bands of `segments` quads: the vertices + /// of a seam are shared, so the surface is closed. + static func sphere(radius: Float, center: SIMD3 = .zero, rings: Int, segments: Int, material: UInt32 = 0) -> LODTestMesh { + var positions: [SIMD3] = [] + var normals: [SIMD3] = [] + for ring in 0 ... rings { + let polar = Float.pi * Float(ring) / Float(rings) + for segment in 0 ..< segments { + let azimuth = 2 * Float.pi * Float(segment) / Float(segments) + let normal = SIMD3(sin(polar) * cos(azimuth), cos(polar), sin(polar) * sin(azimuth)) + positions.append(center + radius * normal) + normals.append(normal) + } + } + var indices: [UInt32] = [] + let stride = UInt32(segments) + for ring in 0 ..< UInt32(rings) { + for segment in 0 ..< UInt32(segments) { + let next = (segment + 1) % stride + let a = ring * stride + segment, b = ring * stride + next + let c = (ring + 1) * stride + segment, d = (ring + 1) * stride + next + if ring > 0 { + indices += [a, c, b] + } + if ring < UInt32(rings) - 1 { + indices += [b, c, d] + } + } + } + return LODTestMesh(positions: positions, normals: normals, indices: indices, material: material) + } + + /// A flat square of `size` on a side, `cells` x `cells` quads, with a V-groove along z + /// through its middle: `grooveWidth` wide and `grooveDepth` deep, walls included in + /// the grid, so that the groove's vertices carry sideways normals. + static func groovedSurface(cells: Int, size: Float = 10, grooveWidth: Float = 0.6, grooveDepth: Float = 0.3, material: UInt32 = 0) -> LODTestMesh { + var positions: [SIMD3] = [] + var normals: [SIMD3] = [] + for row in 0 ... cells { + for column in 0 ... cells { + let x = -size / 2 + size * Float(column) / Float(cells) + let z = -size / 2 + size * Float(row) / Float(cells) + let half = grooveWidth / 2 + let depth: Float = abs(x) < half ? grooveDepth * (1 - abs(x) / half) : 0 + positions.append(SIMD3(x, -depth, z)) + let slope = grooveDepth / half + let normal: SIMD3 = abs(x) < half ? simd_normalize(SIMD3(x < 0 ? -slope : slope, 1, 0)) : SIMD3(0, 1, 0) + normals.append(normal) + } + } + var indices: [UInt32] = [] + let stride = UInt32(cells + 1) + for row in 0 ..< UInt32(cells) { + for column in 0 ..< UInt32(cells) { + let corner = row * stride + column + indices += [corner, corner + stride, corner + 1, corner + 1, corner + stride, corner + stride + 1] + } + } + return LODTestMesh(positions: positions, normals: normals, indices: indices, material: material) + } + + /// A box of `size` on a side whose six faces have their own vertices, each face a + /// `cells` x `cells` grid with the face's normal: hard edges all round. + static func box(size: Float = 2, cells: Int = 20, material: UInt32 = 0) -> LODTestMesh { + var mesh = LODTestMesh(positions: [], normals: [], indices: [], material: material) + let half = size / 2 + let faces: [(normal: SIMD3, u: SIMD3, v: SIMD3)] = [ + (SIMD3(0, 0, 1), SIMD3(1, 0, 0), SIMD3(0, 1, 0)), + (SIMD3(0, 0, -1), SIMD3(-1, 0, 0), SIMD3(0, 1, 0)), + (SIMD3(1, 0, 0), SIMD3(0, 0, -1), SIMD3(0, 1, 0)), + (SIMD3(-1, 0, 0), SIMD3(0, 0, 1), SIMD3(0, 1, 0)), + (SIMD3(0, 1, 0), SIMD3(1, 0, 0), SIMD3(0, 0, -1)), + (SIMD3(0, -1, 0), SIMD3(1, 0, 0), SIMD3(0, 0, 1)), + ] + for face in faces { + let base = UInt32(mesh.positions.count) + for row in 0 ... cells { + for column in 0 ... cells { + let u = -half + size * Float(column) / Float(cells) + let v = -half + size * Float(row) / Float(cells) + mesh.positions.append(face.normal * half + face.u * u + face.v * v) + mesh.normals.append(face.normal) + } + } + let stride = UInt32(cells + 1) + for row in 0 ..< UInt32(cells) { + for column in 0 ..< UInt32(cells) { + let corner = base + row * stride + column + mesh.indices += [corner, corner + 1, corner + stride, corner + 1, corner + stride + 1, corner + stride] + } + } + } + return mesh + } + static func merged(_ meshes: [LODTestMesh]) -> LODTestMesh { var result = LODTestMesh(positions: [], normals: [], indices: [], material: meshes.first?.material ?? 0) for mesh in meshes { @@ -809,6 +1412,10 @@ struct LODTestModel { var compressGeometry = false /// The materials that read a base colour texture. var texturedMaterials: Set = [] + /// The materials that transmit what is behind them. + var glassMaterials: Set = [] + /// The materials with a near-black base colour. + var darkMaterials: Set = [] func write(to directory: URL, name: String) throws -> URL { let url = directory.appendingPathComponent(name).appendingPathExtension("untold") @@ -880,7 +1487,9 @@ struct LODTestModel { let materials = (0 ..< materialCount).map { index in UntoldMaterialRecordV1( nameOffset: string("material_\(index)"), - baseColorTextureIndex: texturedMaterials.contains(UInt32(index)) ? 0 : UntoldFormat.invalidIndex + baseColorFactor: darkMaterials.contains(UInt32(index)) ? SIMD4(0.04, 0.04, 0.04, 1) : SIMD4(repeating: 1), + baseColorTextureIndex: texturedMaterials.contains(UInt32(index)) ? 0 : UntoldFormat.invalidIndex, + transmissionFactor: glassMaterials.contains(UInt32(index)) ? 1 : 0 ) } var textures: [UntoldTextureRefRecordV1] = [] diff --git a/docs/API/UsingLODSystem.md b/docs/API/UsingLODSystem.md index 5370214e8..e262ed485 100644 --- a/docs/API/UsingLODSystem.md +++ b/docs/API/UsingLODSystem.md @@ -260,7 +260,17 @@ The manifest records the chains under `lodChains`, by model path: `screenSize` is a share of the viewport height: the cook chooses it so that the level's triangles are about four pixels each on a viewport 1080 pixels high. -`error` is the largest deviation from the model in model units. +`error` is the largest deviation from the model in model units. A level keeps +the model's vertex normals where they still describe its surface; a vertex kept +from a groove or a crease that is gone at that level takes the normal of the +faces around it, so that it does not shade a flat span dark. A part hidden a +short way behind another, like a car's headliner under its roof or a door panel +behind its door, is sunk behind the surface in front of it by the level's +allowance before it is simplified, so that the coarse triangles of the two do +not cross and the dark part does not show through the paint. What is seen from +outside is never moved, a part that is seen through glass is not sunk away from +the glass, and a piece that is hidden in one part and seen in another bends +gradually between the two; `error` does not count the sinking. To build chains from your own tools, add the `UntoldEngineMeshCook` product of the engine package to the tool and call the cooker: diff --git a/docs/API/UsingUntoldEngineCLI.md b/docs/API/UsingUntoldEngineCLI.md index ff2f74ea3..5cf5e2953 100644 --- a/docs/API/UsingUntoldEngineCLI.md +++ b/docs/API/UsingUntoldEngineCLI.md @@ -237,7 +237,14 @@ A model placed many times is simplified once. A level the simplifier cannot reduce by a worthwhile amount is left out, and skinned, morphing and animation-only models keep their full meshes. Vegetation and similar models made of thousands of small separate pieces are thinned evenly, with the -remaining pieces enlarged so that a canopy keeps its density. The simplifier is +remaining pieces enlarged so that a canopy keeps its density. A vertex kept from +a groove or a crease that a level has simplified away takes the normal of the +faces around it, so that it does not shade a flat span dark. A part hidden a +short way behind another (a car's headliner under its roof, a door panel behind +its door) is sunk behind the surface in front of it by the level's allowance +before it is simplified, so that the two do not cross at the coarse levels and +the dark part does not show through; what is seen from outside is never moved, +and what is seen through glass stays where it is. The simplifier is [meshoptimizer](https://github.com/zeux/meshoptimizer). ### Animation-only exports → `.untoldanim` From a2b154d9478eba59d884f69a8f78a6566d9b8c52 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Fri, 9 Oct 2026 17:04:15 -0700 Subject: [PATCH 10/11] [Bugfix] Use single-bounce reflectance for IBL diffuse energy conservation (#1329) * [Bugfix] Use single-bounce reflectance for IBL diffuse energy conservation computeIBLContribution attenuated diffuse by the multi-bounce-compensated reflectance from environmentReflectance, which conflates two different things: the energy specular's own approximation needs restored via later bounces, and the share a surface diverts away from diffuse in the first place. Factor the single-bounce term out into singleBounceReflectance and use that for the diffuse term instead, keeping the compensated value for specular only. Fixes #1296 * [Chores] fixed formatting --- .../UntoldEngine/Shaders/LightShader.metal | 7 +++- Sources/UntoldEngine/Shaders/ShadersUtils.h | 2 + .../UntoldEngine/Shaders/ShadersUtils.metal | 20 +++++++++- .../UntoldEngineKernels-ios.metallib | Bin 783256 -> 783048 bytes .../UntoldEngineKernels-iossim.metallib | Bin 786664 -> 786472 bytes .../UntoldEngineKernels-tvos.metallib | Bin 783576 -> 783384 bytes .../UntoldEngineKernels-tvossim.metallib | Bin 786888 -> 786696 bytes .../UntoldEngineKernels-xros.metallib | Bin 784088 -> 783864 bytes .../UntoldEngineKernels-xrossim.metallib | Bin 787320 -> 787112 bytes .../UntoldEngineKernels.metallib | Bin 781784 -> 781560 bytes .../EnvironmentReflectionShadingTests.swift | 37 ++++++++++++++++++ 11 files changed, 62 insertions(+), 4 deletions(-) diff --git a/Sources/UntoldEngine/Shaders/LightShader.metal b/Sources/UntoldEngine/Shaders/LightShader.metal index ba63087cd..cfc71e5cb 100644 --- a/Sources/UntoldEngine/Shaders/LightShader.metal +++ b/Sources/UntoldEngine/Shaders/LightShader.metal @@ -301,8 +301,11 @@ EnvironmentLight computeIBLParts(texture2d irradianceTexture, // Every surface reflects the environment, a non-metal too: that is the sheen of // polished plastic, glass or lacquer. What it reflects it does not scatter, so the - // diffuse light gives up that share, and a metal scatters none at all. - float3 reflected = environmentReflectance(f0, roughness, NoV, iblBRDFTexture); + // diffuse light gives up that share, and a metal scatters none at all. This is the + // single-bounce share only: the multi-bounce energy compensation in + // environmentReflectance exists to correct the specular term's own approximation, + // not to measure how much the surface diverts away from diffuse scattering. + float3 reflected = singleBounceReflectance(f0, roughness, NoV, iblBRDFTexture); EnvironmentLight light; light.diff = max(1.0 - reflected, 0.0) * (1.0 - metallic) * diffuse; diff --git a/Sources/UntoldEngine/Shaders/ShadersUtils.h b/Sources/UntoldEngine/Shaders/ShadersUtils.h index 43c88f17d..a0ee3ccd6 100644 --- a/Sources/UntoldEngine/Shaders/ShadersUtils.h +++ b/Sources/UntoldEngine/Shaders/ShadersUtils.h @@ -167,6 +167,8 @@ float4 BRDFIntegrationMap(float roughness, float NoV); float3 environmentReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap); +float3 singleBounceReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap); + float3 blurredEnvironment(float3 direction, float roughness, texture2d specularMap, float3 rotationAxis, float rotationAngle); // adapted from "Real Shading in Unreal Engine 4", Brian Karis, Epic Games diff --git a/Sources/UntoldEngine/Shaders/ShadersUtils.metal b/Sources/UntoldEngine/Shaders/ShadersUtils.metal index 3964e1187..c4a416591 100644 --- a/Sources/UntoldEngine/Shaders/ShadersUtils.metal +++ b/Sources/UntoldEngine/Shaders/ShadersUtils.metal @@ -498,12 +498,28 @@ float4 BRDFIntegrationMap(float roughness, float NoV){ } +// The share of the light around it that a surface reflects in a single bounce off its +// microfacets: F0 head on (4 % for a non-metal, its own color for a metal), more when it +// is seen at a slant. This is the share diffuse scattering gives up to specular, before +// any later-bounce energy compensation. +float3 singleBounceReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap) { + + // The lookup table is read right at its edges (a surface seen head on, a roughness + // of 1), so it clamps: wrapping there reads the far edge and leaves a white spot. + constexpr sampler s(coord::normalized, + filter::linear, + mip_filter::none, + address::clamp_to_edge); + + float4 brdfIntegration=brdfMap.sample(s,float2(NoV,roughness)); + + return F0 * float(brdfIntegration.x) + float(brdfIntegration.y); +} + // The share of the light around it that a surface reflects: F0 head on (4 % for a // non-metal, its own color for a metal), more when it is seen at a slant. float3 environmentReflectance(float3 F0, float roughness, float NoV, texture2d brdfMap) { - // The lookup table is read right at its edges (a surface seen head on, a roughness - // of 1), so it clamps: wrapping there reads the far edge and leaves a white spot. constexpr sampler s(coord::normalized, filter::linear, mip_filter::none, diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-ios.metallib index fd876f82ce29ad9f14718f2ef8bea58442828dfc..9aff1ed387b288139589a2c04c384bc274d24720 100644 GIT binary patch delta 2645 zcmbu9eM}Q)9LJx#YvJyIdMz!Splb^#3W9B50s`8W3h0Cs&`suB3mqeK3PgOFCR}Z8 zDFGSmbcJMzpqnqrUXUqeDyATb_!_nu=ga;8wxOGw1L6x#27B6yfMA^4AHUr5yL+DJ z``q0ppPJkAJARojNL#U7g#Z+I0$}6=06GS$1&dI_{$rW5V?#yT%=^B*_@Hb7FnqQx zJAZKylHHkj*UT6$VYWwmVYWSj$LbT*D3Ckv zu~!rHXjO`9Hl-G@`ouK|#kM9^p|QYf_C=>sx4SUhY(M#loSHfb$p zNOSj;dK22GV-4zROjF^yWeQ(X8omU9g~GyMabR&_dBF0thcCe&*Q3@V_RXvyK8WP{0Nf}UMuNd+@49aPI{CG_o(FSxhOOJ*c-T3}^?_>! z;TkLB9HgvmBDqZ@@@Z@UOalEf0Ko$w&=OG1{~pGBqzH}LZgB$@ur4Nt(W<4m-|#N7 zo?k)F$iR(e=4Vwp$G5t*&7l>H@UdW0+0Zv_g|%GU?y#z;c|({;om7)+;H;(f_yL|N ziW-q~q9rEz?&v;qoX65ZuCIfsRN|f5O5}}pU6r)s0x&6uv&7+jI`7s&%nQbko?jJQlPf_p99_J2 zF=L8%rsR1kWrN%SgE4* zO;#5Xw5yfv8o57G=XzUX|eV6>QAMs^~PU+vqR7yq0TKMHx zwEAhtcCkq{wDY2Z7o!UlQWf2nu26^eWUJ_q_9PO%`-aC#+;GF9#TVdK1ON_XV6{aG z*oY-_IXYDb?t}x_Po@w9C>llBzg$Kv-)F0ND~- z0YX^p8#fbQDW?WncH&l2-qcmjS)r4RrhSKnEVH9tU(|pZ^%p^(6BQ=-=DY zCIP*24Co}0;{O4ihQ(%HFox7dZqND4IOm+~ cNMnTkuAD6LvK`rgdY2X_lJ??6uaKsH0L~DpasU7T delta 2717 zcmbu9e@qis9Kc^+d-bjqI4hPSDpvtv0@{wLbb`_!h+C$s;#^_4wa}^2O$2`g&Gg#C z)}PesX^O^dMO}2ux}k2?IcG}6$tKCbEgNcL%9i2JXxSF083mP7PU>E?*Z~#EvFFPgEq27IT-uRrfxk=eSz1~wug4hv` z_?5PWsn!qA#Ci0iyB@4swPF1}&um>$2@V46J2{Kei3RV!CtWIdvtw^!DzofhO3|J% zB`3{`KX^;~^c7vz@1KO%f)#9*YBawABiP|%;@O6Y5_YwUO51i}|HTO&ndid02lktn zJDxlJ-Gnu_42z5tDspaHV-sEtZ(hNkR*m5|tPby;lOSRPIXH_wYvNNk3AQWqDc5f$ zq8Qfw>^y$(0m07Akh9WhZ^9K~wmx6R_K+g>@U%*3OhoAfSK4log!N{{u;yGHY*mJy zvESyJp>YB@8ttg6!*bbP}6tRi}_?iIIqyG=37$3^M{%We*QCka|ydwyO6J+ z6V6H9a_B2#ExMh2s+N0HlTolKqkxA3qTr(tpukWFQ4I5D6yOi)VM`^uplBTLvg?L{ zSmyos8h$*1_o8Yu@g`sBa_lIx4SH1qLw?mV;E|13Q(SWBMLRU!v zkw_~>8H2Sl*<75l(Qf||L9(zgjxly5ecCWWU9hM@p*Ye+H~BVe$jom3$j)&xrQ!f7 zi0aA5E|Q%x${H_BV6LB~qMK;3I{GW4*c9zEIph+5qagTBImtXzSMHNk$;|k%Y@d;& z#gbr>GKV|Bf?a9vG&<<|sEa12JlGDtp+r3@p#pD5DPA+xM~Rz~YBb$S!bdj+!u6#a zlJS}1?dgI>{|vo+Dx zp=*Swd~g4@HoQ}|*-ghOPV&3X2Vzha_azM}-q4t9?udp(lx-fs^uBFv8-dJRZOf&w zIHS6ISOSx$u&IQ3D=-#))jewDOtF)zWVTMf<3`=>nTD-K(sitfO9~j=F6hL&jTZ^37Xc#Ok z2R*}Ul11u<;EvM!JAl+Fg+HICq(0hV9SQfNR!U6q3Maa;1pwW0*ZG%mf8VtoOl?W7 z#%|kI;^=-$a9^2Ie#BPkn}~jgs4M$!mNwUUu3De3uqVP3pyr0KG|1erE7Lb#vX|hN zOLikZ1>ejQAPQfx%K@8(WqRKzB)TEgio8poC!UXC>lTaQ^$L#UwO&N>$pm4Q41i+F zRly_d-mI0xN9wo%IHSXM>tTT__%V^z3p5=9+S&X>pzmX^-9I-7=(b*VNyul*EpCz=CZzbXVgRE~1p zG!RR@abFU;cwVu$I6#z7i#vrF?00t`?5&BS0kG?vo(TJ|Uo8K1u)W(hc{CP}AMC9~ zu=l~e!7?^d;mi9K?ru$dRN?+)q;NXya9dXhZqt}Y9DX(A@IZvacdh*=hX-r^48t89 zsPKD_RruN1St&@8bg#orGdQ?)NJ;$;cc%<;c)m%;Ih@%&*x^_l5psAW+*Sm4S$#o- z!oiM56;6j8ZreA|;r@DE*x^|rhhLQI2E*Ol?{KaTuP_2`16N0juNzw7v9vyi9q4f8 z_!Awj9oe1|;qa>i9DaAIHn-*6R!-r}=R+yHbx#&1%oKEJ?u){3uOBONNk(kS$SJ$P qq`W_|y6O)myATn$d#71 z5S4+aschLSYJACD21czqH3d5bUltOLQ*lW#j+wYDg*gRnVArb%2u9uh_~m}T>+?L{ zyXz;PN7d4!dMPijXr7V*Aix2@!UF)rHS)3`*YVbZ>8Wuj$8oBAwcz}z?Ntv2*EgLV zD$d0(DtUca`@!G4(KT1|RE0}n;MhRgQdh8}1omR}$KS9X8nKP`MGBl91 zr4>LL7TJsoNpAX=5D25&VGJdYq;G)0bZU&ZT5MFbJu#5fWvHNr4DUViR)!A33+dIE zDwxz|E{9widgP{~=O9XFW*f)`(KJOCW!IP>y?|CdYP{uQm$v_<0E}A*_6G4sbA`w zG1WQ-MiQ#s%!VfVjxiy}l90oo3ZjZh6^kl1Ren@)>^8smmFT!(yN zujz*`g)=VO)m&t8Hvb3epATUvW@6MeimE;NtQhOb1xPHB%bf+Zh~%;LLGFpFV^vy2 z3>(}WgBT{BNF$_7Ty&734FIq~Jj93s8@aZgFq`bQ?ZH1Yw#Bd9f8vO@(FRa^2BlQlTs}T zGvO#?W*FlV|uTzldw8 z8tQBVw;#ge(mtPnKbjV?EjHjIvW0UkT?_g!=(J6t*tlB4+#A zD&$AS1?_ueosxiboLzUkgOz`QV96QOTkHv!$EfnB2>Zug>FvuT2d%Po|LD>!33{9rN0lj*E|eN7?1Yw{F2u_JTH0_U7v>iP=&PCB&ar}5U)I( z@4M?`zrk@!{*ckQJ-Y|n^C&j*J*k5Aow)e!w{Qssf?hPZ3eqy33?|4ekdKdE`l SBF~g@Eqm0ceUI9|=*+)QC9ksp delta 2719 zcmbu9dr%Wc7{E81T)Ybj91%j0szFe(f+n>p5sXuv9V`o}l>eRsS2 z?d|>MS8G>xI+W3K7tGP|00hGT*cc4}=zKFe8+z*wnZ~83tfr+Uhwy9H-YtxhWUuX7 zh|R5@vQM5}`RgCi*?>`)V?&*r_XO^^O zG+8cx{oQWEd!<^c@fPG&hZftJ&(tINtUa`LUc88DOUD^(hLx}RjbOS`CwOv~i6R;A z3=>pfOpQj)h%?qeHjYbsND;F?qXe?!Q2Ds0X|G7i_|hU7Tc!aTvx3i<^O-gX7I3pk zgOssl6+o|*E5#Wi=4#ffu%711;9jLOOCY%`SZ>c=3+q4RUNG6~pz;cr+NVkxwPqPF zz<4#=V0{mx((d59Wf9zWUqNTTseO^pjt$i-bftXF6QTT`ZUvvz_T^G$hkglPvp7_f zhUL5#hB9pD8+UR~$|vS*oS4Hy0a5T#2vA@sgeZpjCg$KL55lV@o^K}ONuEnZ#4Emv zU+|;xybHeBV#2JKe1ZP?aDg_JMB_NmOD1BJ=kzlK$(!!kVSy(>A-|4p=XAXNT8G> zEP<_Z`FxyqQeJ&P9C8sQ6tAw>yB})Abg23J)l78}?B38OeZo>~xVJ##jmIg+v z(zy-n*p-VNmMW?y;;hx37`O<|DMUSLp%TA{O1ykJs}MJiF4uLd2$pJW3-y<6NWy1{ zx19pE41@A!9HT(>A4B4ZqU`M3_G&Drb5?67vRy9nnuvA$i;d;4VX=s3` zeee05t$2rglb4E79^rR2wMC*X-b*@CyrCh})-Hj03a1HRMs{;+1(4gyoS6zN#-i;W z7Efm>ooZp$N{m5Ybw?39OYG)4*&H)(deP~2P8OomJrAeBMPBEk6IekTKmDB|xl?U? zL+!H5Y}Vd3i8EIxM$u-f3^SXZ3*}pbee`%ip@gU~Edo8! zT-2=qUNtIHeoi*s}oLa#z!HxWDh(-W}VVREFJf zzJ{aw&Bxh7cVexxgq?(bhp21Htu%e6`&gM#qjV*}k3jh~VL^bt=2E3pTyo{(_De1c zJ`LZ*6Cer~yAlDDhNb%En22r&%SFDY783c9p3VcfgtwA;Hg61Xy$_Mx6hEwfq!4Ii zxaS!$;qs+zAU@E>4B#2v zIA72{?GZR%?RWs^MrRAhc{t{+2y?$wabBzq=e*EA^}(ETecH1)&WFaF8qE2U z&i}^wPh(F!nDbl8p)i+)b1n~ZF0kqBglE2qQ26Tn#03an>-M>h5%(mX*<}$D-s?f) z;nWeH%SFV?zU1>nUL3T_eQU21MHw-lAPD>I-B0^C(E!?wjSr>$_VMMn2m8BqW1Y@k z=Lb8sBHH`t-e4aUF7f6468E+w+%Iu|5|TI-vbeJ=NVj$5eHQ;dXz{i%i*H~1Zx#=f z{}rM;Fi_%m9w_mnQL~egCaGSFTeCU34M<5VCk$(Bfwk4TI@E(Qk3C4=*>2ZZp?MkFOhA;!%__ zof&9x`oo7>TpxQeIn3hU53u;H>H5s(W0jo5>CcCfc*|R9m@rk)uDdG=(Yv;uK+2fwe=WL@hBS;62hn$ zT%Ab&sKGk5cBWHMhj=$&X27G?86At-I^Z~nwqwDQY6kkU5dp!WwtxI)-*5N5_kFwj znNQQhaR=JQ@iVgLDmefF-T)Z*0063jpMqSo)yyB2I2x&0xq7?T@h!h!KWVmfdmTT$ zHRbSu$oq|a^56}!#MO|Y%36v68*?x*Kf38J>S1WygA3L@S9cR3(aRc5(dnYclfqGQlV^j>VFc5hh*`_#5CM!m&Yy{xqC(}5aHb=6 zjJ0(Y;m_En%ty&rI5RIs!UQLOfmnQ*Dmle;lLT`lc|D>FgY8)BgZ=Oj?)BQKDg=yo z-(zk})gqR3b~dRBWVF+kb4aFcngt;@zujWv^!13^;%@h*Y(bijun(A&t%&*;cBq>a z$Rx@0Ie@Xr_aL^Xj7(99t|NTdx4#1q|5)(}svG9%*D5!n?NOd_mvSvyJ+WsT$W*4T zKpmN$o>Z;jNEuqS7q#tX?-*jzN@LPEun<@%EG{e_EH7Bz_Lwy6)*-~ao>`a`!MSL~ zm*T7K``WM*!;osJeKvwGOcne9e|->F5lg~rB&>aEZj|*z8a|qy={o`B;gUbB_hud| zK3bfIi!pt>w_c3V&!nMJR4)34ljjF8y|{}L2{!oJT0(5Ho3?w;$7~zB^5C%}?m?a( zT+<2Hm}&Q5=89T^S4(gXCx*f#&>;noKLEl^p%wh=Fy3pXK(A>LRgJsSiUm6(-t|g-$g@yB<&uu^RSb-`l_-C23!xqhTcI<{Lcui`#aKDVg*T$@S@$8}DGP z)K#$=P6vK%V5i*GKq6bK0lb)|Q?f*MRwM5`sZTN`VeL7-b)BdWjPE(WGV(y47@6m2 z_IHYCL$W(1-$xoJU`Abl%Mm{#2+>%ogiquG^*UaMNQ=GD30kRVI$?UQ zMbc!*qGWP5$-?{FVThn)1?MSvx+45nOrr?SZ3+H-M?kZtJV($9qFO0rNs;kQjPYfo zaVoa5O1NAioKH(8vN;lpw}{e`=~Y6K?-Utc|Ou+5sbMV>zRw)nRTHBr@ew?)|&Y#*L&Zmaqu_g|% z@E}Llj}Y8spI33^m+!Ee ziavZFYY*w=yNaOS^8IIh#sIz-^zxl5l>a}zQ-if1%=eO}UcS}F+y8>^Cf>Qh`A!vf zMGWA32IL#_?)Xo9+jyOQ_%`D=-_JK2Q~KgV`7SEE^M1aQuY3^S6w9|(slz>dzdFhC z{o4Nc4F1TEkvqB8f((dvp&M^R9BL(&;-A?IFX3q+NKvZ&ObecqEI10+4$!qUoETcy z{Qfwfs|hRJNTPG)u8qvvAH2#9Zwolm`?mp0P6`ZWXa&F`wbuHoT69J)2_~Qw zJU#89(`vBOk#@?IKF0W{ZHOp{bOvmvf=(^8GgPhB&^nH7sdRHejo_o&Kfc-TyW8Dw zZ|^t1$}1Dvu1<)UHFu_#g8(E1K}sSJ1Zmq9kqM}(Pjli@#%P+G=3O}dfl>G|Px8*v zq{M5SQ`@HD*Is3ubqk`%xhH4oa|>{!gFcwDB)-h_&B-4#GA$MRbK*5$cl@??yM9Nk zjY;nKA%fDN8~+H*MOM>Es%Vtr5p=}xA$09{F}+?T8}mz^BbsoR|2dRtTk+maMTX@2 z6KO(k!)WgAtZ@_7<;MeySJPjrhNIMmz}lCh_;g1K&YYdW52xKpVf4spdKCGUpt}>t zGI5LeVYF*n4%+e;LC;B$(~{ITfKfn&+|;F;)u zMt_%P0mc{D*&@A|wxkzwTIjlT3bed+e~a}QYk+8%zucX<5xA<^7j)((AZlS#YqFSD zsh4m(v`f7cm~PQZ%|28S8p?k7C3yH7nx&|EXrNxDU60nr1@gPvRp{#Ed%2k2r(1|x zX9sGLekI38lltALse*k{l3*xJFmPZ17$^)b3=9SjMukq+>xA%%-Q+G z>+U^Q@Gqi(Z?by^Ao6s=ui#%F#MLB<;26iemQ9Rc&KU?1XDV|bn~2x$;58#H4a+?F zC}NkDk#NNAvb#1J{!@%UDMy->R_7w?nf#LN7Y<+luy}LR0e>=wsnObMw8+>B9y2YA z7)C0FntbI_=^WhdAYI;N+*sc9NV};s=2-0@jbTZxLQ!2$)_cmdqQo9_aMwtwQgKMc z4eeE9=S5vonQaI!%6{dPOju6}G{Pp6z%2BbE97Et9oKhovB*BTaCCQ1Uurnb>2*)e5zgLoP>A5>1s69Gg8#6-0B&Th`^yPJWEi)&RSbHVXLXa90oQ^ zbqakQfbILv?`Xxlq-8ELQgIaRZs-VuU0m&2kzh+*nx#_+a%GMj1T%WxZrzGVEya#B z85U{M^n^s&(-jUCFMTye!>_t68=NJe*iIH(#tj!d-TnzYc)C}>6focAnBRyMcAzPh zi=_^gahu9%l~~MuZR8mnHm%^ff zZ~9`SH=-n#uc`IzEWEQ5kx&xwdxK2kAuDWy!MpG%1?C|N3f@>A0&lsyVIJgXbhY*Y z&?l$?KRz_^d$dB%2-pJ-2hpe*k)e?J+!-(v&@rZ$>CrcckLU(d$+C(?ZD%*tE+_1s!o zqqWv}k$vSn1i7c;E!Gjij$V1+(XN(Z10C&+g^nfzl6G|anKlm}AnD6~Np}QEdiTah zN!nNPkDuwPP~XFjzWrE7p9;?&1uaPSN!pyrGOdTsxi4wgsD6@GoAs=u?R)x58jB?S zk`8vX4I*7sWe9RKvU8xL$$+FC`yZCHw@M$7bdq1v=jHnTOgG+_G~0(Won<=U=zO-1 z4&U^2M~9Qf6#8LF+dqGzq;-SaMg>Xw@xfZfR*L zQ3-f8^)*-AoI2+QZn4gZDcDTJxslAbO9sZMiDnk&gDHWX-lBkDnA;z}+~4o^obx@m z`Q+1Rl39&1{;ceoDi(mi7XT|C0Dx8TQ=#)j?Yz;6QGc}8rH#LEKH}@cDRIS32d>mw zHa|G~2>UhKzwYv_ZOiA(QfDo~fO8#fPh1gIy&GG2+@${I`PvobD}4_ayHoBzw(m}E zTS}<+Saw!5c^9`7(XmOx*(4vQ`6EKH&siI%u7l(O&$u&nGpzZJ`GHQ|0%f{DdlIk_kUF2zZrUubpsSDuReV)lQHoa_YItvv<#YV+J#YN?V%GWVA9ou{Y z+Sk!JSrM$uwu^dvr6c|?%ry+I+U|J4!*kLEClCt39OXC?JtI-=(r`xE^cs8&mBE_; z@^I-x+ntP)#m9>Ca0zB~`x+$}^+XmXV=Kg;v-11_W|VZXBEfo|y*1P>zh=MlGN)@ick)W2HPz#?n&+_Y}eUA~6glfi@XHJOU7I39I7&hT?rP1V(MExP}Z{ zmXJ$n)lw{^tAwcLZx;=piy6(-UDa$(a8-SSQybLHXGaps8h2o}b(_}Zw5rH)oot~x zZBL$ovs9$V4h5Jdklk`lyu>6gjdz=qe5QABgPl}`5^L60u-6nBD1QIdyX_FS&}ffj#uBo_CxWn z?yrj6lP7_5oh<<_F=a~jq~!Z4WgXmhgE;}qR0^im2X;E+rUgT7Wwo$fA<(Slwu$xF zGlSrs=!rp?nOiBnVagK86--hk_iv{uLL@IdCqlQ|gkQ(B%8=aFkX0ptE!qm5;2s!t zPXrfkGWW!opEsJPV9Tn7OQgbilr)~nk&3tG22W=~U?d@nYF=fx*741#sx8II-^3U&twunJnuQK3X zw42T|Xuouv`w4&HPrF?l+vz#bpS8h*HyjyeYtrK%IyN*A0cU7-{+x!PZY=O#RBt#oG4}zOTgmLl3=_nKCr28Gvtg$Btez zH+b+Z-7vt+g=NUhYkm8e`OaWwJ|Ecb#kU5jwU3w^Jz}N?H*+dk)yK@848EYekC}~C zZ}Y$es{{sx4 Bzoh^G delta 2704 zcmbu9c~BEq7{E6#*{~Y|gvb#@WjPcS&}393+&MrxRVNBsM63y>>au4b-0Cm%zQwWKxit(cQp4VyadmThck>09EZc_@-x=}5pE z#`Z^4MJi_GuQR(R6B;sJJdUaG$4US3!DRhZKDk~@M6Wo%_O^q)ToxhrFKpF3=e1()F$NX+@xIpR3%Nz6of)rC*Tgn}PExo$6-t zNpZ#sQ166gHB*Cl(?@?zqGu|C-T75`lsl4@Ox+}Ry+*p8$(!!Z`=x7`PRZk(Pu9qm zGKm6r&6BSN^%_zq-@z1qL_addW|qfhGGG80Oc*Q}C=50XFI#LTwy6$WFCrJ^L@=&V zTh#bFwi$n6%^~1}D%&G2zDVYC5>haRC5hv~aS|1hgHNE|SKvIxENWg39wXhxK8Lim ztg`1Z5d%@7g(GGl`t=0%TbL`ULe7bF=4HCmdB)0L4&MH}xa907u4Fz{C)L+Uk;&C; z>Xrfz)e6TdomB$Ce9U0dS{|(tv<#9_JQ`B8PAcrt5Kv0E!YkL_Y zP#2q02sA(`>GKLPqzX-9cB%#?;i(=FfmvJ&-AS!ayJLmB+dGX7clRbp0E?}r#jR*T zCo^%+a)C*#*djLTg4C+PHsNU-!u>&$Lib%2=>9P(7I3xk5#vpEfzxo) zEQ%?+Y|h7Ym(5Bn0jprJAcafJ;RqRz#@YH7;C%oXIkra&@qAyhVyh2BLz$;}McN?9 zXF~7}6@sYsa0NBM#m%;^Qv55)FrF7TJ%eXU>!>{UqRVZ|g+^v28a{QJb#(RdcV49^0Z=aeRz=RRA~Y5b87wI`I}!&v81 zGnhG+s#N1|+b&+j7X|}!yDj!Mu1XF#=ILgb?^1wF${Njy9<;O8WoJe6s5IX@z53p8 zJGYnDNp*D&q^63EAdgL4s+-`c=+#dYZEXu3sc1(eRJ7LZXj6}iY1Q}mIYpl@^JLEA&$N~OO|r(R3)uw!w)Y~blZpVC_9eT zCGF)&7H!b_+z%lCLZ|F5OsKJ@sYm`F~15AfoZmfDV!2->ITZ-mX~F7Y-QQr HWk>%7mAma> diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xros.metallib index 14a75ecaeac9ac47aa7565467fbfc6c7802cacd3..d3a5c09a2100323668371cca5323af806899b7c5 100644 GIT binary patch delta 2612 zcmbu9c~BE)6u>u|gzP2|myo~;)g%N3-4 zF}Q65?H_`jR_!qbYsq*vP+IV)L#I|eIvqfzSldou)Y8g8zid=MaIo#pyxI4%-}m0P zo8SDd{yt~m?i|6wB@5IH2!;>{Du*Em8n|C2NP_DRHDu3>3%-^A-R15t_xsMSOexcL zPqkNXzEW`iT-hA$_D^$z;N(jSH90F0h@cL}tqbNakD967@L}}rz~1y%*1Q|jJ>|Mt z9I&E;wd?r#s*cRB+-nP|u=r_A2?wRTr}}Q>g!6-)Cju|eP(=6E!I3e= z-|E6!U$nh-YHRh53SOHoX=QYUyFo2=8Z@xJ;cl>KDwpbvLn!uKEmQs*O7(@!vK`Lj z@~PUn*-Ym{lv*4rrF;`M!i9XQPKi^)7?(PnumvWjfpRvbNz}ugk*?JC<0s%4;+Y)k zY&eIym#BdYGwB_eMnLK3t%jY;>C!WSOWm5c5q1{SrK{hzq%E-gfU7)^yaRR|r5{kq zJ7MMbbZUqeQ1Qxju=6%3D`D$>N};M@W_j}HPjCag!5^xZnM8oQ-mKonY!7zlL+Z^; zL(Jn`K-HwIVb*84YfQ5VmaL@=nkuGYKmC&_G_^D|l>q_<4D7`V$oyq(3GVQWuCgUNKSNJxiD@7lW4 zkL^CXTZc*zqmyHlAS6rSFJ~$xA24)Y5Mq=JF=Wu|p0!*kM3RFbV3DLjXSr3b6QBdH(>$d!!4EM3=Y$^UsSdAPJ2W z@g1t-)_GUpFDyokX0lhkh~-no_}W1ThP|0GuBzU-e@R(6(c&mmV_}0bktU^DXJF;y zdgKt_6p0NhSWyy_q9h6{jrVv7=lD3tN)^&hR5IpY)LNx&=OL47P?6yvTRPnheAnyB zP4Sk9+)|Z=bmTA-O(BE7;0{S4s3o4)A(I>;ATm}6>;>ccJ^7!7;lM32DF|{156$m|M>iBS+!0A zFLreDTg0R(!8K(aPbG&q{RVR^lA#i&HBA}pj7al=32U9GUn$HgWcP{n$OD702ft?! zWffSZS4}y%LP<}mX8+|d1>%bJXK`>k1KNQQD&K-G-`Dm`=_D$(!XCqn9vog#Y<@JR z^v^bPB9d1pS}hf2ld33sjudAv$Msl7oe1-85u2ewh0y2}KYEwKy+0wD46VwqnXHtG zjPHXlzof}Gi`ybLsRvJ8Q1TPB0Ya>z%hDO_AWk-kJ|a#sLQAfBEJY00ECdpRlrbQP z1sGUmkwR2>>MWL4-3EjNE_-7J+7H7z)c)~&bcjz`cd%^8&nz!SAr;divjK`xMQB zK#Otu{u8uE*aM@WwW3#^4J|#U^nn{%lBV`~pf&8h@oZ>!UHn(j;xx2+wE?v?WS|oJ zofBvW45vJ_H=jn|<5PIKCtOiPEz1dHNbEHi(9iwgE-!oLO|&3EcoaZB&b*H4+{w)= z7>nzfhJZ7Z;VJ>zZR9bFVrcU|HZ4g^f5L1P_rxP+Ni$i}ylEV?O9u9hn6}vkZOQg= zrY&=s);ij>zfNS@n^XGTrfmnR9K~#}OSI&~rcK7GMwxbyhE}q7lxdCBQ>I1Cm^W?O zWd%>0c0tgHY2D0va8+`vS+vI-OFl7e>@!WfX{>3nQtu|(w2dy)W|a;<4J{G;*tGN< zcEGt2)2il;746!IOq**Kqa&t;zZhfMgiA{PhAuH}T1WV#rfq)*nATn)<(?M!JeVDq necl*U6H#37w=twue&krVeN_~qleQc(ggH+1v!2~vesSITlM9ZcaqM$xrO5#2}d=pz|xfHEGIZ_t`dBXCdhr;fjl^8|E4ViZYG{YZRK7=h^FOk9UHNb zl@uH?3)x#nVs{e!$u{Jg%xqg?zN{;+`sv7nW2O(U?Dr=-9G&X2PBoHR%|(zdDFPWo zkdd|#JECtOh=e;M#-eXaI4K>W_iYo2=3*8rb+}ccq$0 z>5;^?j}ghF2MJDi-%Rv6(JmrcqquRFj*Dc3hvKUuuITxOh$cg|Sa7AD<2z79SSHmJ zxdo*nBlh(~x1ON*0$+km&b*)!z1`BTudWThWw48VEyxX$*Qel0u~t}OTJ6Eb!>-_CR@F3W!CguC0D&V{?vgDGIX z(>lKiUDV2!*A$7Yiq#)0Y-XX+(BDU#{ed{jqBEg7o%J1ZTXO{>%EgRJ?JCl$b0g7x zG7cEdjjJlKQ|Kt*n^A=HMVCu>suRAb!tU*e&@KdjHj+X&Rb3Vf_QS0#FbtR4;g!up z;4ODGzKb;sT-(KQ*CZzNfpr}QFL(jgwA3y>YF*<_gP()f^(ZYj(|O6HRZ49m!D*!Y z9(R$?a?d79s<>%efSGUF^wHQ9tdhln@GP{65jqu3aV?*T_W-bt=PI6s_eRn+%lN># znx0oMhPBB7;a=h5EW``qST|g{Qan=~F@$hO;aNQ1ap5#h^z*Iu^X+{#-`(hv?n@zj zN7n@So?Oj!q`B~s6cL2-7wTsypR*63JVT;v?DXP!1+p#E`5!>nFLO$CZ5!-y7+$uK<|ilz|t`DrIn%x$vv_SHpop z=&HMeFU^RlgMWkPWG#*74Ux6o^GaDizFhJ+RMydzooaJu1M=xMDC+?SQ|9OJW-&(9Kjp&ekqJ(ru%klXJUY&K*H=-nr>Ta_%mF8jy4MP&xO8%6X9J zU!!J@h4!QRlm%r8(5i zR6x$weM9B!snrDJoa&eJ4`NLy(j9|xX8N#Z1j$*?^vT5oM71IWckvoma)gvOl$wP jB(+G7Hl(>WPvmulJG_rr7`pFFFpMa>o)4| diff --git a/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib b/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels-xrossim.metallib index c315f7ca34d88d01d3303892c9f1f16c67f68602..faa1b73354675494168628a6b855f80d577a1973 100644 GIT binary patch delta 2612 zcmbu93rtg27{_lGGBu9G_aj?zi6c54Fm>oU*y$|_C;SJ>+h*zHm=3+*3ih zn~eNMKDUw2`YIs=CL!H21agNUp{9^(!Fd?(l_AoT9g+q-B!6lFMXJNlz`kl;t)P+^ zGY{1psZP~gPCzy5J3A@t7qG*5%KEMkb1Jryt@a8P9_tDhtJ8LAb)2O{A-d1cFbVIM zb0$j-^76^JIniq-;T>S7s+4FuS;cz&ti}@7b{a7#UGhvj)#~&d5V%si)sSe4&oe7c zlzl!s*%0OWm9R^T;VkvJh8Scx<-_Yf6@l~%44(YF&TUdcF58E5`2Cs2>*y`woU{%u zM|?YxtqRvE9DGoZz=tV?kS8#!+PTfRKGBqj_G)|_y=*>=e{z3S_%5v!%(FZFS|yYr z$(^#CuaqI)Jv!r5G*c;>;qZ4k<7Wf_(o!q#QHZjNx!saN^r234i@2{7XBAk&t{CPM zas`vLi~GoK7)!`kHWTo6I_xq^Dgz5T0#}#$JIN}I=$3BWEdnemF%HC-|7oo@!N_7Q6WIe<-@TjN6cD2lTj@aBmVzpw$ z|07l#SjZ4dG046gv5T$@5}Rkd_7cQ8xlJPzs}=W+Wr(F1WIe>rfW+e7-T#T$I&SX} zVlCLE7Zc0Gm>+tGr5I#ihFIN}>n|pD^VxqzEWr?4sM2B9?rcnIo7sw82Eg!_ZR{!R zpr5sCC!0ebTj2xh*3k>*k7Y&J9$mz~4FbJswi9=;f+W#li1~Bwl|}PLcdu|bvrP@5 zO`|a@h2m}Gv)4^w-223}6#42?aI1O82f(F_RC!C$bL8gqZX0xMi<{i?4bQo@!tGki zP}klb$+f@u_jp{}4rMt6+)lS@sgYfqidPSDt&1U7x@CxK_4G5YMUA+CaqWVFXI(ox zV$ii7aJ_g+nZ+nBGft(Rx_0UdU0XEVwYXW}U|ieic5Rlq|5d=X{(xJFSK~Xywy`{7j zU$uJYs`CZTC|lH_o7Nb0Efi(WEHJkWUkPkVa1kxWDcMkVdJ9bHA_W z{`#viNxIDv!OU4Rlnej?4!{-&0MIeF3(}#a_C)Sz*_e|7WyvEg-Fx;0{`uDvJfID#IykvWBUDLQq}tV{L1eaE4G-lXIau zfU3?EQrwg^5Jb>vFONeVNhyV3EGoxQs#GnsW_VH?cA6ugqDBW$7ZL)fN2w~9Ifp*M zQwb<-+I&VU9R26aA8{)DVC?Emyv8Mtd?ql6DoM2B2GO%} z5&nCWC)oiyi*AhENlR}Bmlt$K%_ZhJO3&G z(d?-q26bE12}y+7y;UfDA2*oDLv>47{SVI84pn3qIV6%A(nY!)l}cWGPjqPeNTF16 zkjDz_ore9)YZrx!yS_DBiRLAqMK z{lnHr<5Rd>Vp-0*j9l>~gWQ~i-$(m1nJJEuyk4m>Gb$dS#Th(>YoT|QIma8-7R?Yy z3bQ0CCq(W0<~KFt?ZV2c5Xl*4*QM4Ws7ci|C6DWHrfS=QV4ld73$UEV&CNy<2(>y> zst5~FD|!OL4QUdSoSjyJ?L@!ou_$y3*GhLPDo?*(g{J#rJR41?hRJYlm1%AxHouiA z+f^tu$xAlM&H6B{rmv4UeYH5)kgvn?^G&lwTbqqQn1yQ>SrsBvz60=kr7Sp%9lj~g zN@ByIduk!*b_?rx_-(qAQyN&@Fde`T(!% zzqa~OSE6;;J=01YUGO}-agkMAV=8qdqR+wUdXSivTGgP-$&{EQ;b~BDmp$KYxNDZi z8n2n>;reT4H6KsLD;X>V&xd9)pc1fndrdCU1L3zE`{CIH47Ryt44nFIF%O?-%?`R#CCs_?~GTQ~95IMZ#~(rBE?H}mX|^Ma25hBI?? z$S3ebaB}ue_8V@JC*v*IN&P#%a$Ytd=jx`&J~l)oUstXU(V$SW^Hx0kDT#s9%&ACGwGGHY42b; zyQ)=QIVX7J{EJxSPjknBoasJ{sXlU6(|xk=x|hh=59V!Tj*J>CXTz!2%DFJKCB{e2 z?Sthk%Syf3u!)wl;rz?w>vT3G8k-mLnt_HVL4JTuD7hjA$+nty&b6+6sn4pobzAXHaL@keCEU zWWa3=*qIt^)zWDl&<;B~9)Z#kY3(p=$AYK@tLU_j4b)1i8Pd&0+Mgl_a*qR$y}{uXhFae04jX|0I=V$_g@aSG`6mvCy8)$55pC+{h*kJR=4Q2k1yDGVVM zH5-$X4}Hun{X_5GYb_z-q+!qigb;pnxI|05Ke0ZI@0hz;M4Xe(fh;@Sy=#L}Vn6~Dtfje7&s~HVj*T`S%0_(%%hL6b zWfURS#R!QY*)~vw5>0X*;lxnlgsc=41XJY#LcOd2v?S2!PgE*Tp*LY_f1)61GuV?s zmtHcIxSg~O>?xy5*DZD}F9oq$S9x&7ZqRa!{()Gr2gI(>sdlkHA(a<`)0z&;5x;RW?+Y>aH>Mb_Pb^>VuHh zNMF&%B%5NA8B~B&KvXcPU{T>gg(n%448LswYf6cY8R3j>b8{B*E*Uullfj@(MBZ@1 zS*pMHG+x{(wTG3X(}XYORCpGARiI_ZoZXzs-n3f9}&Pho2O0y;}1n4 zB1q2vgpuzJz&gPgBLdjzWf|mJ#8)l1{)*naAm_-b6RsrRo9eMsJyp0XNzHCUS#2oe zQ%MK_n6IgjP#g=%NzBuzg>cYVCEDay!wXvn>kRm9O^qKvzcQjBUjVMdtph%te7(#yrI4)<*;yl6Ln54}2ux}Bby(w50zge& z6K6yon7)HG%rAf+X#_9(B5Mug^BUi!gz-xsZT_g48GuwmGQR?(c$KG${K8d-ZfmqF;aUja7Ior-T zt~twy+1KZ>0oL{zHSiL+iU9yj3b40~LV$=%j%Ma6yI?Xp1sMfF6+(uuL{|9_4Mkqy z7bV2T&Ipo%^;|F*Yb1+G5vg)!PQN;hMB%e>8a|cNigs?rY@BX!bK2?R6aa=r002^J zs4=v){zvLF@kkBSX$JYHY+MRn+)Zy2ia(?Ge#e^U=-n_uuZko8fAp$?3TS$1c=4B` zcT?{Kz3GN)FF|iF>-^00syJieG`%#uxSQS-ieA`r_&?EWVc93qTZLSGF}?H{(<3*% zG`#rB&|6;J|6+P&-~B6kc{IHRN-g4o10WB#AtNBT@CjLa26^CZPO~w*8D%cxg2E8< z=UK=*WMmhT#swQxYA=x`Rj{CpgcVSD3h2ko<_oErP^;Q)#qD7&(B6R zpHgod3KcA&sUD}eLi5Tb1)q5eF>b)M9owIyIMaS$!oeLbiuaX0=ip)!<=`EjlN@|& zCI^4*JL0CeWvYU^Tnff#cJK{f8nR0VIH z$-(IcJ~H88uyu-qpInjq6b|xf2V3K2b#Tvzl!M8ZIp{h6fYAk#yl!3O;rR0RAM56K bi9YLyBXJSBn@w1MW18$Tl(fs(1Fio6jlQT! delta 2668 zcmbu9eM}Q)7{IT0y?R#)92H8zuU1fTd|k&7Dj@WOkIau$ae#=@0zsWoJ`@8Qy|z$b zPO)-o)h)BQWMUQ;92<4cEv2FmMFVIu7#RUKi7XC_F@{Z)y}hFHF>!8xJb9nz?#=r= z_uenRk?zp0o>2bk^{eD8Eey?qAe|coL0x|w<*$H^r!TBq9Jyps;GVJsRqpw_)V^(h zuDmfcwzA<+_*XXtN3!qL=B>J1w_2H;jzM<%Ok{4rS+hB*M@Lu(9u)gnTUb1w{ zrF)q!)i%%Vg+<|m&b>rF9W0%TkZhdxnCDH`hwx2_;aol)5bU-j<*Bq%uwnl3?p>mz zJx9qCNJ{Lnq@&x`+?L&m+AJ#B;XEOqzAT-G#O-z-u*Qc=_eEkfx?F?Q-^c0Uprxk9 z46Yk(UcL^|j^Om#KrtXC2aQ%9wq23fCXOKcCH(1rcCfYyq=+9cZYOzpL_(t|&pdvLyZ|Mkxt$y!sLTvsQDmR500w-opsiu6_6 z4-18BF};B@+qSa(IEkKmb^n6z>SxR1bL&a6wwY?SR?3N>QN(Ltp-@7eCD<-wacCzo zAQb7mIX?RPS46JO6i?=QUCqCwK>%qI3FP;bf6v9fYc^A9s{N!kl75vE>Fj_-Q{X* z8CJ<+0}3~miy=A~4YKsb8bdK zma-N+$s4{HZ@JZrXGOVQnxy8zx!g%SQw}w&nrGzs@wJ_gr>eQ5qE4=@vq7g0b0Fx2 zh7V}xPxSN7mwq<4&zbILn;-Bq^s-ZVk?It1M}LD1DD|0HO8=`#n_H7@qa zetz;=KVNZA@&^W_#s#g3WvEsH-@FvG*?)?l<1|V}(E4Lj1&w;*4na@!vkpL=RTDqK z&(M+Sex{s)Hk_U;Xj_faDd=E_pgYCNsZ_VV6g1O^mB>)-^m7{1M}h5qv!C55RU|!G z(E5fM1+DPv@}D5+-bsRf7@>%6yLyoEv;K!S`MLdgFv<$Dw9VyS=FhGSj4ZsN_y01a eSpQTX&`s8MhFCrc<(9Zq2&1?bVU$bXg}(vNn&Gqn diff --git a/Tests/UntoldEngineRenderTests/EnvironmentReflectionShadingTests.swift b/Tests/UntoldEngineRenderTests/EnvironmentReflectionShadingTests.swift index 7f2ae699a..9947e639c 100644 --- a/Tests/UntoldEngineRenderTests/EnvironmentReflectionShadingTests.swift +++ b/Tests/UntoldEngineRenderTests/EnvironmentReflectionShadingTests.swift @@ -204,4 +204,41 @@ final class EnvironmentReflectionShadingTests: MaterialShadingTestCase { // 4 % reflected, and half of the other 96 % scattered. XCTAssertEqual(gray, all * (0.04 + 0.96 * 0.5), accuracy: all * 0.01) } + + /// The share diffuse gives up to specular is the single bounce off the microfacets, + /// not the multi-bounce-compensated total specular reflects back. At the low F0 of a + /// true dielectric the two nearly coincide (see `testAGrayNonMetalScattersWhatItDoesNotReflect` + /// above), so a mix-up between them hides there; a material blended half metallic + /// carries a high enough F0, while still keeping a diffuse term, to pull them apart. + func testARoughHighF0SurfaceDiffuseGivesUpOnlyTheSingleBounceShare() throws { + try buildScene(.sphere, towardsLight: nil) + try lightWithAnEvenEnvironment() + let roughness: Float = 1.0 + let f0: Float = 0.5 + + // A white non-metal gives back all the light it is lit with, whatever its + // finish: the baseline irradiance, by the Furnace-test identity. + let irradiance = try shade(material(metallic: 0.0, roughness: roughness)).y + + // A fully metallic surface of this same color scatters nothing: what comes back + // head on is specular alone, F0 equal to its base color, which totals the full + // multi-bounce-compensated reflectance under even light. + let compensatedShare = try Self.facingBrightness(of: shadeFrame( + material(base: simd_float3(repeating: f0), metallic: 1.0, roughness: roughness) + )) / irradiance + + // Blended half metallic with a base color chosen so F0 lands on that same 0.5, + // this surface still scatters half of what it does not reflect (1 - metallic). + // Solving its rendered brightness for that diffuse-energy share, with the exact + // same F0 as the measurement above, tells us which reflectance the shader used. + let metallic: Float = 0.5 + let base: Float = (f0 - 0.04 * (1 - metallic)) / metallic + let facing = try Self.facingBrightness(of: shadeFrame( + material(base: simd_float3(repeating: base), metallic: metallic, roughness: roughness) + )) + let diffuseGivesUpShare = 1.0 - (facing / irradiance - compensatedShare) / ((1 - metallic) * base) + + XCTAssertLessThan(diffuseGivesUpShare, compensatedShare - 0.03, + "at roughness 1.0 and F0 0.5 the single-bounce share diffuse gives up should be noticeably smaller than the multi-bounce-compensated total specular reflects") + } } From 1085580c4699c96ed2a85d1afd54ed4b8447c11f Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Fri, 9 Oct 2026 17:04:42 -0700 Subject: [PATCH 11/11] [Bugfix] Report animation clips that match zero joints on the bound skeleton (#1330) A clip whose channels never resolve against the target skeleton's joint paths (namespace prefix or hierarchy mismatch) previously registered and played silently: currentAnimation set, currentTime advancing, every joint sampling its rest pose, with no error at all. Adds .animationClipNoMatchingJoints and a diagnostic check wired into changeAnimation, restartAnimation, and the two auto-select-on-registration paths; registration and playback still succeed, this only makes the failure visible. Fixes #1318 --- .../Systems/AnimationSystem.swift | 45 +++++- .../Systems/ErrorHandlingSystem.swift | 3 + .../Systems/RegistrationSystem.swift | 27 +++- .../AnimationClipJointMismatchTests.swift | 140 ++++++++++++++++++ 4 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 Tests/UntoldEngineTests/AnimationClipJointMismatchTests.swift diff --git a/Sources/UntoldEngine/Systems/AnimationSystem.swift b/Sources/UntoldEngine/Systems/AnimationSystem.swift index dc759cec9..a128cb754 100644 --- a/Sources/UntoldEngine/Systems/AnimationSystem.swift +++ b/Sources/UntoldEngine/Systems/AnimationSystem.swift @@ -366,6 +366,33 @@ public func isAnimationComponentPaused(entityId: EntityID) -> Bool { } } +/// Reports `.animationClipNoMatchingJoints` if none of `clip`'s channels +/// target a joint path present in `skeleton` — e.g. a namespace prefix +/// (`"skel:LeftArm"` vs `"LeftArm"`) or hierarchy mismatch between the +/// exported clip and the skeleton it's bound to. Every joint would otherwise +/// silently sample its rest pose: the clip still registers and plays +/// (`currentTime` advances) with no visible motion and no error. This is a +/// diagnostic only — it does not block registration or playback, so +/// intentional no-op/placeholder clips still work. +/// +/// Checked directly against `clip.jointAnimation`'s keys rather than through +/// `AnimationComponent.compiledClip(for:skeleton:)`: that cache is keyed by +/// clip identity and considers a cache hit valid whenever `jointCount` +/// matches, so compiling here against a skeleton that is later swapped out +/// for another of the same joint count (same clip, different rig) would +/// pollute the cache with a stale compilation. +func warnIfClipHasNoMatchingJoints( + entityId: EntityID, + name: String, + clip: AnimationClip, + skeleton: Skeleton +) { + guard clip.jointAnimation.isEmpty == false else { return } + let jointPaths = Set(skeleton.jointPaths) + guard clip.jointAnimation.keys.contains(where: { jointPaths.contains($0) }) == false else { return } + handleError(.animationClipNoMatchingJoints, name, entityId) +} + /// Default halflife for inertialized clip switches, shared by every public /// entry point (`changeAnimation`, the node builder, USC `.playAnimation`). public let defaultAnimationTransitionHalflife: Float = 0.1 @@ -405,6 +432,14 @@ public func changeAnimation(entityId: EntityID, name: String, transitionHalflife to: animationClip, halflife: transitionHalflife ) + if let skeleton = scene.get(component: SkeletonComponent.self, for: targetEntityId)?.skeleton { + warnIfClipHasNoMatchingJoints( + entityId: targetEntityId, + name: name, + clip: animationClip, + skeleton: skeleton + ) + } animationComponent.currentAnimation = animationClip animationComponent.currentTime = 0 animationComponent.pause = withPause @@ -1045,7 +1080,7 @@ public func restartAnimation(entityId: EntityID, name: String, repeats: Bool? = handleError(.noAnimationClip, name, entityId) return } - for (_, component, clip) in components { + for (targetEntityId, component, clip) in components { // Restarting playback does not change compiled channels. Invalidate only // this clip when its looping setting changes; preserve warmed caches. if let repeats, clip.jointAnimation.values.contains(where: { $0.repeatAnimation != repeats }) { @@ -1054,6 +1089,14 @@ public func restartAnimation(entityId: EntityID, name: String, repeats: Bool? = } component.compiledClips.removeValue(forKey: ObjectIdentifier(clip)) } + if let skeleton = scene.get(component: SkeletonComponent.self, for: targetEntityId)?.skeleton { + warnIfClipHasNoMatchingJoints( + entityId: targetEntityId, + name: name, + clip: clip, + skeleton: skeleton + ) + } component.transition.cancel() component.currentAnimation = clip component.currentTime = 0 diff --git a/Sources/UntoldEngine/Systems/ErrorHandlingSystem.swift b/Sources/UntoldEngine/Systems/ErrorHandlingSystem.swift index b592bd361..67a978e68 100644 --- a/Sources/UntoldEngine/Systems/ErrorHandlingSystem.swift +++ b/Sources/UntoldEngine/Systems/ErrorHandlingSystem.swift @@ -98,6 +98,7 @@ public enum ErrorHandlingSystem: Int, Error, CustomStringConvertible { case manifestDecodeFailed = 1086 case assetIsAnimationOnly = 1087 case physicsPoseJointCountMismatch = 1088 + case animationClipNoMatchingJoints = 1089 public var description: String { switch self { @@ -275,6 +276,8 @@ public enum ErrorHandlingSystem: Int, Error, CustomStringConvertible { return "This is a .untoldanim animation clip, not a mesh — use setEntityAnimations instead of setEntityMeshAsync" case .physicsPoseJointCountMismatch: return "Physics pose joint count does not match the skeleton" + case .animationClipNoMatchingJoints: + return "animation clip matches zero joints on the bound skeleton (check for a namespace prefix or hierarchy mismatch between the exported clip and the skeleton)" } } } diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index e093742cc..b1c06ba6a 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -1124,8 +1124,18 @@ private func registerUntoldRuntimeAsset( if let animComp = ensureAnimationComponent(entityId: animTarget, errorEntityId: entityId) { let registeredNames = registerRuntimeAnimationClips(animClips, preferredName: animClips.first?.name ?? "", to: animComp) appendAnimationSourceURLIfNeeded(url, to: animComp) - if animComp.currentAnimation == nil, let first = registeredNames.first { - animComp.currentAnimation = animComp.animationClips[first] + if animComp.currentAnimation == nil, let first = registeredNames.first, + let selectedClip = animComp.animationClips[first] + { + if let skeleton = scene.get(component: SkeletonComponent.self, for: animTarget)?.skeleton { + warnIfClipHasNoMatchingJoints( + entityId: animTarget, + name: first, + clip: selectedClip, + skeleton: skeleton + ) + } + animComp.currentAnimation = selectedClip } } } @@ -3950,9 +3960,18 @@ public func setEntityAnimations(entityId: EntityID, filename: String, withExtens appendAnimationSourceURLIfNeeded(url, to: animationComponent) if animationComponent.currentAnimation == nil, - let selectedName = registeredNames.first(where: { $0 == name }) ?? registeredNames.first + let selectedName = registeredNames.first(where: { $0 == name }) ?? registeredNames.first, + let selectedClip = animationComponent.animationClips[selectedName] { - animationComponent.currentAnimation = animationComponent.animationClips[selectedName] + if let skeleton = scene.get(component: SkeletonComponent.self, for: targetEntityId)?.skeleton { + warnIfClipHasNoMatchingJoints( + entityId: targetEntityId, + name: selectedName, + clip: selectedClip, + skeleton: skeleton + ) + } + animationComponent.currentAnimation = selectedClip } } } diff --git a/Tests/UntoldEngineTests/AnimationClipJointMismatchTests.swift b/Tests/UntoldEngineTests/AnimationClipJointMismatchTests.swift new file mode 100644 index 000000000..b115b3ab5 --- /dev/null +++ b/Tests/UntoldEngineTests/AnimationClipJointMismatchTests.swift @@ -0,0 +1,140 @@ +// +// AnimationClipJointMismatchTests.swift +// +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import simd +@testable import UntoldEngine +import XCTest + +/// Coverage for GitHub issue #1318: a clip whose channels resolve against +/// zero joints on the bound skeleton (e.g. a namespace prefix mismatch like +/// "skel:LeftArm" vs "LeftArm", or a differing parent-chain path) used to +/// register and play silently — `currentAnimation` set, `currentTime` +/// advancing, every joint sampling its rest pose, with no diagnostic at all. +/// `warnIfClipHasNoMatchingJoints` now reports `.animationClipNoMatchingJoints` +/// whenever such a clip is selected for playback. +@MainActor +final class AnimationClipJointMismatchTests: XCTestCase { + #if canImport(AppKit) + private final class CapturingSink: LoggerSink { + private let expectation: XCTestExpectation + private let predicate: (String) -> Bool + private(set) var messages: [String] = [] + + init(expectation: XCTestExpectation, predicate: @escaping (String) -> Bool) { + self.expectation = expectation + self.predicate = predicate + } + + func didLog(_ event: LogEvent) { + guard event.level == .error else { return } + messages.append(event.message) + if predicate(event.message) { + expectation.fulfill() + } + } + } + #endif + + private func makeEntity(jointPath: String) -> EntityID { + let entityId = createEntity() + registerComponent(entityId: entityId, componentType: SkeletonComponent.self) + registerComponent(entityId: entityId, componentType: AnimationComponent.self) + registerComponent(entityId: entityId, componentType: RenderComponent.self) + registerComponent(entityId: entityId, componentType: ScenegraphComponent.self) + registerComponent(entityId: entityId, componentType: LocalTransformComponent.self) + registerComponent(entityId: entityId, componentType: WorldTransformComponent.self) + + let runtimeSkeleton = RuntimeSkeleton( + jointPaths: [jointPath], + parentIndices: [nil], + bindTransforms: [.identity], + restTransforms: [.identity] + ) + scene.get(component: SkeletonComponent.self, for: entityId)?.skeleton = + Skeleton(runtimeSkeleton: runtimeSkeleton) + return entityId + } + + private func makeRuntimeClip(name: String, jointPath: String) -> RuntimeAnimationClip { + let channel = RuntimeAnimationChannel( + jointPath: jointPath, + translations: [.init(time: 0.0, value: simd_float3(0, 1, 0))] + ) + return RuntimeAnimationClip(name: name, duration: 1.0, channels: [channel]) + } + + func testMismatchedNamespaceClipCompilesToZeroAnimatedChannels() throws { + let runtimeSkeleton = RuntimeSkeleton( + jointPaths: ["LeftArm"], + parentIndices: [nil], + bindTransforms: [.identity], + restTransforms: [.identity] + ) + let skeleton = try XCTUnwrap(Skeleton(runtimeSkeleton: runtimeSkeleton)) + let clip = AnimationClip(runtimeClip: makeRuntimeClip(name: "wave", jointPath: "skel:LeftArm")) + + let compiled = CompiledAnimationClip(clip: clip, skeleton: skeleton) + + XCTAssertFalse(compiled.channels.contains(where: \.animated), + "A namespace-prefixed clip path must not resolve against the bare skeleton path") + } + + #if canImport(AppKit) + func testChangeAnimationReportsClipWithNoMatchingJoints() throws { + resetEngineTestState() + let entityId = makeEntity(jointPath: "LeftArm") + defer { destroyEntity(entityId: entityId) } + + let animationComponent = try XCTUnwrap(scene.get(component: AnimationComponent.self, for: entityId)) + _ = registerRuntimeAnimationClips( + [makeRuntimeClip(name: "wave", jointPath: "skel:LeftArm")], + preferredName: "wave", + to: animationComponent + ) + + let expectation = expectation(description: "mismatch reported") + let sink = CapturingSink(expectation: expectation) { message in + message.contains("1089") && message.contains("wave") + } + Logger.addSink(sink) + + changeAnimation(entityId: entityId, name: "wave", transitionHalflife: 0) + + wait(for: [expectation], timeout: 2.0) + + // Diagnostic only: the clip still registers and plays. + XCTAssertTrue(animationComponent.currentAnimation === animationComponent.animationClips["wave"]) + } + + func testChangeAnimationDoesNotReportMatchingClip() throws { + resetEngineTestState() + let entityId = makeEntity(jointPath: "LeftArm") + defer { destroyEntity(entityId: entityId) } + + let animationComponent = try XCTUnwrap(scene.get(component: AnimationComponent.self, for: entityId)) + _ = registerRuntimeAnimationClips( + [makeRuntimeClip(name: "wave", jointPath: "LeftArm")], + preferredName: "wave", + to: animationComponent + ) + + let unexpected = expectation(description: "no mismatch should be reported") + unexpected.isInverted = true + let sink = CapturingSink(expectation: unexpected) { message in + message.contains("1089") + } + Logger.addSink(sink) + + changeAnimation(entityId: entityId, name: "wave", transitionHalflife: 0) + + wait(for: [unexpected], timeout: 0.5) + } + #endif +}