@woosh/meep-engine 2.168.0 → 2.169.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/build/bundle-worker-image-decoder.js +1 -1
- package/package.json +1 -1
- package/src/REVIEW_2026_08_06/adv-core.md +511 -0
- package/src/REVIEW_2026_08_06/adv-gfx.md +480 -0
- package/src/REVIEW_2026_08_06/adv-view.md +446 -0
- package/src/REVIEW_2026_08_06/api-core.md +296 -0
- package/src/REVIEW_2026_08_06/api-engine.md +195 -0
- package/src/REVIEW_2026_08_06/bvh-phys-sound-net.md +507 -0
- package/src/REVIEW_2026_08_06/core-binary.md +348 -0
- package/src/REVIEW_2026_08_06/core-collection.md +376 -0
- package/src/REVIEW_2026_08_06/core-math.md +221 -0
- package/src/REVIEW_2026_08_06/core-process.md +266 -0
- package/src/REVIEW_2026_08_06/core-science.md +223 -0
- package/src/REVIEW_2026_08_06/engine-ai-nav.md +279 -0
- package/src/REVIEW_2026_08_06/engine-animation.md +167 -0
- package/src/REVIEW_2026_08_06/engine-asset.md +166 -0
- package/src/REVIEW_2026_08_06/engine-ecs.md +920 -0
- package/src/REVIEW_2026_08_06/engine-input.md +197 -0
- package/src/REVIEW_2026_08_06/engine-misc.md +248 -0
- package/src/REVIEW_2026_08_06/engine-terrain.md +52 -0
- package/src/REVIEW_2026_08_06/generation.md +176 -0
- package/src/REVIEW_2026_08_06/geom-2d.md +434 -0
- package/src/REVIEW_2026_08_06/geom-3d.md +117 -0
- package/src/REVIEW_2026_08_06/gfx-gi.md +143 -0
- package/src/REVIEW_2026_08_06/gfx-render.md +740 -0
- package/src/REVIEW_2026_08_06/gfx-texture.md +690 -0
- package/src/REVIEW_2026_08_06/prior-criticals.md +49 -0
- package/src/REVIEW_2026_08_06/prior-highs.md +138 -0
- package/src/REVIEW_2026_08_06/test-gaps.md +364 -0
- package/src/REVIEW_2026_08_06/view.md +667 -0
- package/src/REVIEW_2026_08_06.md +571 -0
- package/src/core/binary/BinaryBuffer.d.ts.map +1 -1
- package/src/core/binary/BinaryBuffer.js +12 -1
- package/src/core/binary/BitImage2.d.ts +5 -1
- package/src/core/binary/BitImage2.d.ts.map +1 -1
- package/src/core/binary/BitImage2.js +5 -1
- package/src/core/bvh8/build/NodeProxy.d.ts.map +1 -1
- package/src/core/bvh8/build/NodeProxy.js +314 -308
- package/src/core/collection/list/List.d.ts +15 -0
- package/src/core/collection/list/List.d.ts.map +1 -1
- package/src/core/collection/list/List.js +34 -0
- package/src/core/collection/list/SortedListProjection.d.ts.map +1 -1
- package/src/core/collection/list/SortedListProjection.js +4 -1
- package/src/core/collection/map/BiMap.d.ts +25 -2
- package/src/core/collection/map/BiMap.d.ts.map +1 -1
- package/src/core/collection/map/BiMap.js +57 -3
- package/src/core/debug/matchers/IsAnything.d.ts.map +1 -1
- package/src/core/debug/matchers/IsAnything.js +5 -1
- package/src/core/events/signal/Signal.d.ts.map +1 -1
- package/src/core/events/signal/Signal.js +3 -1
- package/src/core/geom/2d/polygon/TRIANGULATION_DESIGN.md +433 -0
- package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.d.ts +31 -0
- package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.d.ts.map +1 -0
- package/src/core/geom/2d/polygon/polygon2_is_counter_clockwise.js +84 -0
- package/src/core/geom/2d/polygon/polygon2_signed_area.d.ts +24 -2
- package/src/core/geom/2d/polygon/polygon2_signed_area.d.ts.map +1 -1
- package/src/core/geom/2d/polygon/polygon2_signed_area.js +52 -34
- package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.d.ts +28 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.d.ts.map +1 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.corpus.js +86 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.d.ts +40 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.d.ts.map +1 -0
- package/src/core/geom/2d/polygon/polygon2_triangulate.js +1280 -0
- package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.d.ts +28 -0
- package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.d.ts.map +1 -0
- package/src/core/geom/2d/polygon/polygon2_triangulation_deviation.js +77 -0
- package/src/core/geom/2d/triangle/tri2_rasterize_conservative.d.ts +6 -3
- package/src/core/geom/2d/triangle/tri2_rasterize_conservative.d.ts.map +1 -1
- package/src/core/geom/2d/triangle/tri2_rasterize_conservative.js +26 -13
- package/src/core/geom/2d/triangle/tri2_signed_area.d.ts +17 -1
- package/src/core/geom/2d/triangle/tri2_signed_area.d.ts.map +1 -1
- package/src/core/geom/2d/triangle/tri2_signed_area.js +35 -23
- package/src/core/geom/2d/v2_morton_encode.d.ts +20 -0
- package/src/core/geom/2d/v2_morton_encode.d.ts.map +1 -0
- package/src/core/geom/2d/v2_morton_encode.js +23 -0
- package/src/core/geom/3d/atlas/segment/atlas_merge_charts.d.ts.map +1 -1
- package/src/core/geom/3d/atlas/segment/atlas_merge_charts.js +4 -1
- package/src/core/geom/3d/polygon/polygon3_compute_normal.d.ts +27 -0
- package/src/core/geom/3d/polygon/polygon3_compute_normal.d.ts.map +1 -0
- package/src/core/geom/3d/polygon/polygon3_compute_normal.js +60 -0
- package/src/core/geom/3d/polygon/polygon3_triangulate.d.ts +34 -0
- package/src/core/geom/3d/polygon/polygon3_triangulate.d.ts.map +1 -0
- package/src/core/geom/3d/polygon/polygon3_triangulate.js +94 -0
- package/src/core/geom/3d/quaternion/quat3_multiply.d.ts +8 -2
- package/src/core/geom/3d/quaternion/quat3_multiply.d.ts.map +1 -1
- package/src/core/geom/3d/quaternion/quat3_multiply.js +8 -2
- package/src/core/geom/3d/shape/ConvexHullShape3D.d.ts.map +1 -1
- package/src/core/geom/3d/shape/ConvexHullShape3D.js +32 -2
- package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.d.ts +23 -6
- package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.d.ts.map +1 -1
- package/src/core/geom/3d/topology/struct/binary/io/face/bt_face_triangulate.js +268 -124
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.d.ts +17 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_assert_valid.js +44 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.d.ts +20 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_count_edge_faces.js +40 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.d.ts +19 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_closed.js +34 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.d.ts +22 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_is_manifold.js +37 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.d.ts +87 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.d.ts.map +1 -0
- package/src/core/geom/3d/topology/struct/binary/query/bt_mesh_validate.js +599 -0
- package/src/core/geom/3d/triangle/tri3_compute_signed_volume.d.ts +32 -0
- package/src/core/geom/3d/triangle/tri3_compute_signed_volume.d.ts.map +1 -1
- package/src/core/geom/3d/triangle/tri3_compute_signed_volume.js +66 -7
- package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.d.ts +22 -1
- package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.d.ts.map +1 -1
- package/src/core/geom/3d/triangle/tri3_mesh_compute_signed_volume.js +83 -38
- package/src/core/geom/MANIFOLD_COMPARISON_2026_08_01.md +554 -0
- package/src/core/geom/vec3/v3_cotangent.d.ts +6 -2
- package/src/core/geom/vec3/v3_cotangent.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_cotangent.js +6 -2
- package/src/core/geom/vec3/v3_cross.d.ts +6 -2
- package/src/core/geom/vec3/v3_cross.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_cross.js +6 -2
- package/src/core/geom/vec3/v3_multiply.d.ts +6 -2
- package/src/core/geom/vec3/v3_multiply.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_multiply.js +6 -2
- package/src/core/geom/vec3/v3_subtract.d.ts +6 -2
- package/src/core/geom/vec3/v3_subtract.d.ts.map +1 -1
- package/src/core/geom/vec3/v3_subtract.js +6 -2
- package/src/core/localization/Localization.js +1 -1
- package/src/core/math/random/randomSeed.d.ts +13 -0
- package/src/core/math/random/randomSeed.d.ts.map +1 -0
- package/src/core/math/random/randomSeed.js +16 -0
- package/src/core/math/spline/spline3_hermite_apply_transform.d.ts +50 -0
- package/src/core/math/spline/spline3_hermite_apply_transform.d.ts.map +1 -0
- package/src/core/math/spline/spline3_hermite_apply_transform.js +107 -0
- package/src/core/math/spline/spline3_hermite_reverse.d.ts +34 -0
- package/src/core/math/spline/spline3_hermite_reverse.d.ts.map +1 -0
- package/src/core/math/spline/spline3_hermite_reverse.js +49 -0
- package/src/core/math/statistics/hammersley_sequence.d.ts +17 -4
- package/src/core/math/statistics/hammersley_sequence.d.ts.map +1 -1
- package/src/core/math/statistics/hammersley_sequence.js +25 -16
- package/src/core/process/Future.d.ts.map +1 -1
- package/src/core/process/Future.js +12 -0
- package/src/core/process/PromiseWatcher.d.ts.map +1 -1
- package/src/core/process/PromiseWatcher.js +9 -2
- package/src/core/process/executor/ConcurrentExecutor.d.ts.map +1 -1
- package/src/core/process/executor/ConcurrentExecutor.js +12 -2
- package/src/core/process/worker/WorkerProxy.d.ts.map +1 -1
- package/src/core/process/worker/WorkerProxy.js +5 -2
- package/src/core/process/worker/extractTransferables.d.ts.map +1 -1
- package/src/core/process/worker/extractTransferables.js +4 -1
- package/src/engine/Engine.d.ts +12 -12
- package/src/engine/Engine.d.ts.map +1 -1
- package/src/engine/Engine.js +149 -10
- package/src/engine/EngineHarness.d.ts.map +1 -1
- package/src/engine/EngineHarness.js +4 -1
- package/src/engine/asset/loaders/ArrayBufferLoader.d.ts.map +1 -1
- package/src/engine/asset/loaders/ArrayBufferLoader.js +7 -0
- package/src/engine/ecs/speaker/lines/sets/LineSetDescription.d.ts.map +1 -1
- package/src/engine/ecs/speaker/lines/sets/LineSetDescription.js +3 -1
- package/src/engine/ecs/terrain/ecs/TerrainSystem.d.ts.map +1 -1
- package/src/engine/ecs/terrain/ecs/TerrainSystem.js +4 -1
- package/src/engine/ecs/terrain/tiles/TerrainTileManager.d.ts +29 -0
- package/src/engine/ecs/terrain/tiles/TerrainTileManager.d.ts.map +1 -1
- package/src/engine/ecs/terrain/tiles/TerrainTileManager.js +84 -23
- package/src/engine/graphics/CONTEXT_LOSS_RECOVERY_PLAN.md +446 -0
- package/src/engine/graphics/GraphicsEngine.d.ts +56 -7
- package/src/engine/graphics/GraphicsEngine.d.ts.map +1 -1
- package/src/engine/graphics/GraphicsEngine.js +196 -24
- package/src/engine/graphics/context/WebGLContextFailureReason.d.ts +9 -0
- package/src/engine/graphics/context/WebGLContextFailureReason.d.ts.map +1 -0
- package/src/engine/graphics/context/WebGLContextFailureReason.js +17 -0
- package/src/engine/graphics/context/WebGLContextMonitor.d.ts +106 -0
- package/src/engine/graphics/context/WebGLContextMonitor.d.ts.map +1 -0
- package/src/engine/graphics/context/WebGLContextMonitor.js +341 -0
- package/src/engine/graphics/context/WebGLContextState.d.ts +14 -0
- package/src/engine/graphics/context/WebGLContextState.d.ts.map +1 -0
- package/src/engine/graphics/context/WebGLContextState.js +24 -0
- package/src/engine/graphics/context/testWebGLContextLoss.d.ts +2 -0
- package/src/engine/graphics/context/testWebGLContextLoss.d.ts.map +1 -0
- package/src/engine/graphics/context/testWebGLContextLoss.js +696 -0
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationAdapter.d.ts.map +1 -1
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationAdapter.js +68 -32
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.d.ts +14 -0
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.d.ts.map +1 -0
- package/src/engine/graphics/ecs/camera/serialization/CameraSerializationUpgrader_0_1.js +41 -0
- package/src/engine/graphics/ecs/decal/v2/FPDecalSystem.js +1 -1
- package/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js +5 -0
- package/src/engine/graphics/ecs/trail2d/Trail2D.d.ts +5 -1
- package/src/engine/graphics/ecs/trail2d/Trail2D.d.ts.map +1 -1
- package/src/engine/graphics/ecs/trail2d/Trail2D.js +15 -0
- package/src/engine/graphics/ecs/trail2d/Trail2DSystem.d.ts +1 -1
- package/src/engine/graphics/ecs/trail2d/Trail2DSystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/trail2d/Trail2DSystem.js +2 -1
- package/src/engine/graphics/ecs/trail3d/Trail3D.d.ts +4 -1
- package/src/engine/graphics/ecs/trail3d/Trail3D.d.ts.map +1 -1
- package/src/engine/graphics/ecs/trail3d/Trail3D.js +488 -476
- package/src/engine/graphics/ecs/trail3d/Trail3DSystem.d.ts +1 -1
- package/src/engine/graphics/ecs/trail3d/Trail3DSystem.d.ts.map +1 -1
- package/src/engine/graphics/ecs/trail3d/Trail3DSystem.js +280 -253
- package/src/engine/graphics/geometry/MikkT/BuildNeighborsFast.d.ts.map +1 -1
- package/src/engine/graphics/geometry/MikkT/BuildNeighborsFast.js +3 -2
- package/src/engine/graphics/geometry/MikkT/GenerateSharedVerticesIndexList.d.ts.map +1 -1
- package/src/engine/graphics/geometry/MikkT/GenerateSharedVerticesIndexList.js +3 -1
- package/src/engine/graphics/geometry/computeMeshSurfaceArea.js +1 -1
- package/src/engine/graphics/load_and_set_cubemap_v0.d.ts +5 -0
- package/src/engine/graphics/load_and_set_cubemap_v0.d.ts.map +1 -1
- package/src/engine/graphics/load_and_set_cubemap_v0.js +26 -21
- package/src/engine/graphics/particles/particular/group/ParticleGroup.d.ts.map +1 -1
- package/src/engine/graphics/particles/particular/group/ParticleGroup.js +19 -8
- package/src/engine/graphics/particles/particular/group/optimizeCommandQueue.js +1 -1
- package/src/engine/graphics/texture/atlas/AtlasLookupTexture.d.ts.map +1 -1
- package/src/engine/graphics/texture/atlas/AtlasLookupTexture.js +10 -3
- package/src/engine/graphics/texture/atlas/TextureAtlas.d.ts.map +1 -1
- package/src/engine/graphics/texture/atlas/TextureAtlas.js +4 -1
- package/src/engine/graphics/texture/atlas/gpu/WebGLTextureAtlas.d.ts.map +1 -1
- package/src/engine/graphics/texture/atlas/gpu/WebGLTextureAtlas.js +11 -0
- package/src/engine/graphics/texture/cubemap/load_environment_map.d.ts.map +1 -1
- package/src/engine/graphics/texture/cubemap/load_environment_map.js +5 -2
- package/src/engine/graphics/texture/sampler/distance/computeSignedDistanceField_Chamfer.d.ts.map +1 -1
- package/src/engine/graphics/texture/sampler/distance/computeSignedDistanceField_Chamfer.js +26 -18
- package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.d.ts +15 -2
- package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.d.ts.map +1 -1
- package/src/engine/graphics/texture/sampler/filter/sampler2d_blur_gaussian.js +29 -3
- package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.d.ts +11 -1
- package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.d.ts.map +1 -1
- package/src/engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.js +158 -171
- package/src/engine/graphics/texture/sampler/sampler2d_paint.js +1 -1
- package/src/engine/graphics/texture/sampler/util/bitSet2Sampler2D.js +2 -2
- package/src/engine/input/ecs/util/TopDownCameraControllerHelper.d.ts.map +1 -1
- package/src/engine/input/ecs/util/TopDownCameraControllerHelper.js +10 -3
- package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.d.ts +14 -0
- package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.d.ts.map +1 -0
- package/src/engine/navigation/ecs/components/PathSerializationUpgrader_2_3.js +38 -0
- package/src/engine/network/orchestrator/ServerAuthoritativeClient.d.ts.map +1 -1
- package/src/engine/network/orchestrator/ServerAuthoritativeClient.js +433 -425
- package/src/engine/physics/constraint/solve_constraints.d.ts +4 -1
- package/src/engine/physics/constraint/solve_constraints.d.ts.map +1 -1
- package/src/engine/physics/constraint/solve_constraints.js +38 -13
- package/src/engine/physics/contact/ManifoldStore.d.ts +15 -3
- package/src/engine/physics/contact/ManifoldStore.d.ts.map +1 -1
- package/src/engine/physics/contact/ManifoldStore.js +15 -3
- package/src/engine/physics/ecs/Joint.d.ts +7 -4
- package/src/engine/physics/ecs/Joint.d.ts.map +1 -1
- package/src/engine/physics/ecs/Joint.js +7 -4
- package/src/engine/physics/ecs/PhysicsSystem.d.ts +14 -0
- package/src/engine/physics/ecs/PhysicsSystem.d.ts.map +1 -1
- package/src/engine/physics/ecs/PhysicsSystem.js +16 -0
- package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
- package/src/engine/physics/fluid/ecs/FluidSystem.d.ts +3 -3
- package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts +15 -7
- package/src/engine/physics/narrowphase/convex_convex_manifold.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/convex_convex_manifold.js +33 -10
- package/src/engine/physics/narrowphase/refine_ray_concave.d.ts +6 -2
- package/src/engine/physics/narrowphase/refine_ray_concave.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/refine_ray_concave.js +6 -2
- package/src/engine/physics/narrowphase/refine_ray_hit.d.ts +6 -2
- package/src/engine/physics/narrowphase/refine_ray_hit.d.ts.map +1 -1
- package/src/engine/physics/narrowphase/refine_ray_hit.js +6 -2
- package/src/engine/physics/queries/raycast.d.ts.map +1 -1
- package/src/engine/physics/queries/raycast.js +11 -4
- package/src/engine/save/storage/IndexedDBStorage.d.ts.map +1 -1
- package/src/engine/save/storage/IndexedDBStorage.js +21 -3
- package/src/engine/simulation/Ticker.d.ts +12 -0
- package/src/engine/simulation/Ticker.d.ts.map +1 -1
- package/src/engine/simulation/Ticker.js +18 -0
- package/src/engine/sound/simulation/AcousticSimulator.d.ts.map +1 -1
- package/src/engine/sound/simulation/AcousticSimulator.js +9 -1
- package/src/engine/sound/simulation/core/VolumeField.d.ts +6 -2
- package/src/engine/sound/simulation/core/VolumeField.d.ts.map +1 -1
- package/src/engine/sound/simulation/core/VolumeField.js +6 -2
- package/src/engine/sound/simulation/probe/AcousticProbeField.d.ts +6 -2
- package/src/engine/sound/simulation/probe/AcousticProbeField.d.ts.map +1 -1
- package/src/engine/sound/simulation/probe/AcousticProbeField.js +6 -2
- package/src/engine/sound/simulation/probe/acoustic_probe_transfer.d.ts +6 -2
- package/src/engine/sound/simulation/probe/acoustic_probe_transfer.d.ts.map +1 -1
- package/src/engine/sound/simulation/probe/acoustic_probe_transfer.js +6 -2
- package/src/engine/sound/simulation/probe/bakeProbeReflectors.d.ts +3 -1
- package/src/engine/sound/simulation/probe/bakeProbeReflectors.d.ts.map +1 -1
- package/src/engine/sound/simulation/probe/bakeProbeReflectors.js +3 -1
- package/src/engine/ui/GUIEngine.d.ts.map +1 -1
- package/src/engine/ui/GUIEngine.js +8 -1
- package/src/generation/theme/TerrainTheme.d.ts.map +1 -1
- package/src/generation/theme/TerrainTheme.js +2 -1
- package/src/generation/theme/ThemeEngine.d.ts.map +1 -1
- package/src/generation/theme/ThemeEngine.js +6 -5
- package/src/view/ViewGroup.d.ts.map +1 -1
- package/src/view/ViewGroup.js +9 -7
- package/src/view/graphics/WebGLContextFailureView.d.ts +19 -0
- package/src/view/graphics/WebGLContextFailureView.d.ts.map +1 -0
- package/src/view/graphics/WebGLContextFailureView.js +76 -0
- package/src/view/minimap/Minimap.d.ts +8 -0
- package/src/view/minimap/Minimap.d.ts.map +1 -1
- package/src/view/minimap/Minimap.js +16 -3
- package/src/view/minimap/dom/MinimapMarkerView.d.ts.map +1 -1
- package/src/view/minimap/dom/MinimapMarkerView.js +3 -1
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
# Correctness review — engine/graphics/{texture, particles/particular, geometry}
|
|
2
|
+
|
|
3
|
+
Scope: every non-spec, non-`prototype*` source file under
|
|
4
|
+
`engine/graphics/texture/`, `engine/graphics/particles/particular/`, `engine/graphics/geometry/`.
|
|
5
|
+
(The brief named `graphics/particular/`; the actual path is `graphics/particles/particular/`.)
|
|
6
|
+
|
|
7
|
+
Rules applied: correctness only; every finding carries a concrete failure scenario; `assert.*`
|
|
8
|
+
is treated as compiled out; no defensive-guard requests; unreferenced exports treated as public
|
|
9
|
+
surface.
|
|
10
|
+
|
|
11
|
+
**Executed verification.** GFXT-1, -2, -3, -4, -5, -6/-7, -17, -24, -40 and -42 were confirmed by
|
|
12
|
+
running jest against the real classes (11/11 assertions pass). GFXT-51, -52, -53, -54 (ParticleGroup),
|
|
13
|
+
GFXT-60, -61, -62, -63, -64, -67 (merge / uv-tension) and GFXT-36 (barycentric sampling) were
|
|
14
|
+
likewise reproduced by execution during review. The remainder are confirmed by inspection; the two
|
|
15
|
+
findings resting on unexecuted GL or scale-dependent behaviour (GFXT-29, GFXT-65) and the one
|
|
16
|
+
resting on a transform-convention argument (GFXT-46) are marked `Likely`.
|
|
17
|
+
|
|
18
|
+
**Note on stray files.** Two untracked scratch specs —
|
|
19
|
+
`src/engine/graphics/texture/sampler/__advcheck_gfxt.spec.js` and `__advcheck_gfxt2.spec.js` —
|
|
20
|
+
were present in the source tree at the end of this review. They are the verification specs whose
|
|
21
|
+
results are cited above. They were not written by this review pass and were left in place rather
|
|
22
|
+
than deleted, in case a concurrent session still owns them; they are scratch files and should be
|
|
23
|
+
removed before committing. Sibling `__advcheck_*.spec.js` files also exist under `src/core/collection/`
|
|
24
|
+
and `src/engine/`, along with `src/REVIEW_2026_08_06.md`. No source file in the reviewed tree was
|
|
25
|
+
modified by this review.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## texture/sampler — pixel arithmetic, filters, conversion
|
|
30
|
+
|
|
31
|
+
### GFXT-1 `sampler2d_paint` reads the source X coordinate from `sourceY`
|
|
32
|
+
- **Severity**: CRITICAL
|
|
33
|
+
- **File**: `engine/graphics/texture/sampler/sampler2d_paint.js:68`
|
|
34
|
+
- **Defect**: The horizontal source coordinate is built from `sourceY` instead of `sourceX`, so every blend reads from the wrong column of the source image.
|
|
35
|
+
- **Failure scenario**: `sampler2d_paint(dst, brushAtlas, /*sourceX*/ 64, /*sourceY*/ 0, 0, 0, 32, 32)` — intended to stamp the brush at atlas column 64 — computes `s_x = x + 0`, so it stamps atlas columns 0..31 instead. With `sourceX = 0, sourceY = 8` the stamp is shifted 8 px right and reads 8 px past the intended patch on the last columns.
|
|
36
|
+
- **Fix**: `const s_x = Math.round(x + sourceX);`
|
|
37
|
+
- **Confidence**: Confirmed
|
|
38
|
+
|
|
39
|
+
### GFXT-2 `bitSet2Sampler2D` calls a method `Sampler2D` does not have
|
|
40
|
+
- **Severity**: CRITICAL
|
|
41
|
+
- **File**: `engine/graphics/texture/sampler/util/bitSet2Sampler2D.js:35,37`
|
|
42
|
+
- **Defect**: The function writes texels via `target.set(x, y, value)`. `Sampler2D` exposes `write(x, y, texel)`; there is no `set` method on the class or its prototype.
|
|
43
|
+
- **Failure scenario**: `bitSet2Sampler2D(bits, Sampler2D.uint8(4, 8, 8), [255,0,0,255], [0,0,0,255])` throws `TypeError: target.set is not a function` on the first texel. The function can never have worked.
|
|
44
|
+
- **Fix**: `target.write(x, y, vTrue)` / `target.write(x, y, vFalse)`.
|
|
45
|
+
- **Confidence**: Confirmed
|
|
46
|
+
|
|
47
|
+
### GFXT-3 Signed chamfer SDF keeps a stale `v`, so it stores the last improving candidate, not the minimum
|
|
48
|
+
- **Severity**: CRITICAL
|
|
49
|
+
- **File**: `engine/graphics/texture/sampler/distance/computeSignedDistanceField_Chamfer.js:72-96` (forward pass), `:103-127` (backward pass)
|
|
50
|
+
- **Defect**: Both relaxation passes read `const v = getD(x, y)` once and then compare each of the four candidates against that same stale `v`, calling `setD` on every candidate that beats it. The stored value ends up being the *last* candidate below the original value rather than `min(v, v0, v1, v2, v3)`. The sibling `computeUnsignedDistanceField_Chamfer` does this correctly (it reassigns `v = v0` etc. after each improvement).
|
|
51
|
+
- **Failure scenario**: pixel with `D = 255` whose forward-pass neighbours hold `D(x-1,y-1) = 0`, `D(x,y-1) = 100`, `D(x+1,y-1) = 200`, `D(x-1,y) = 50`, with `d1 = 1`, `d2 = 1.414`. Candidates are `1.414, 101, 201.414, 51`; all four are `< 255`, so the cell is written four times and ends at `51`. Correct answer is `1.414`. The error then propagates to every downstream cell in both passes.
|
|
52
|
+
- **Fix**: after each `setD(x, y, vN)` also assign `v = vN` (mirror the unsigned version), or compute `min2` of all five values and write once.
|
|
53
|
+
- **Confidence**: Confirmed
|
|
54
|
+
|
|
55
|
+
### GFXT-4 `convertTexture2Sampler2D` flips the source DataTexture's own pixel buffer in place
|
|
56
|
+
- **Severity**: CRITICAL
|
|
57
|
+
- **File**: `engine/graphics/texture/sampler/convertTexture2Sampler2D.js:130-132` and `:163-165` (via `data_texture_to_sampler` at `:25-38`, `sampler2d_ensure_uint8_RGBA.js:14`)
|
|
58
|
+
- **Defect**: For a `DataTexture`, `data_texture_to_sampler` wraps `texture.image.data` in a `Sampler2D`; `sampler2d_ensure_uint8_RGBA` returns that same object unchanged when the texture is already 4-channel uint8, and the dimension check `converted.width !== _width` never fires because `_width/_height` default to the image's own dimensions. `data` is therefore the live texture buffer, and `sampler2d_flipY_in_place(data, …)` mutates it.
|
|
59
|
+
- **Failure scenario**: `convertTexture2Sampler2D(someRGBA8DataTexture)` (no width/height override, default `flipY = true`) vertically flips the pixels of the *input* texture. Anything still rendering that texture is now upside-down; calling the function a second time flips it back, so the corruption alternates per call.
|
|
60
|
+
- **Fix**: copy before flipping on the shared path — e.g. have `data_texture_to_sampler` clone when `converted === source`, or flip into a freshly allocated buffer.
|
|
61
|
+
- **Confidence**: Confirmed
|
|
62
|
+
|
|
63
|
+
### GFXT-5 `sampler2d_combine` reads input1 with input0's channel count
|
|
64
|
+
- **Severity**: MAJOR
|
|
65
|
+
- **File**: `engine/graphics/texture/sampler/sampler2d_combine.js:52`
|
|
66
|
+
- **Defect**: The loop that fills `arg1` from `input1` is bounded by `itemSize0` instead of `itemSize1`, so when the two inputs have different channel counts the second operand is over- or under-read.
|
|
67
|
+
- **Failure scenario**: `input0` RGBA (`itemSize 4`), `input1` a 1-channel mask, 4×4 image. For texel `i = 0` the code reads `data1[0..3]`, i.e. mask texels 0,1,2,3, and hands `operation` an `arg1` of `[m0, m1, m2, m3]` where `[m0]` was intended. Every masked texel gets the wrong mask value, and the stale tail of `arg1` persists for the remaining texels.
|
|
68
|
+
- **Fix**: bound that loop with `itemSize1`, and clear/resize `arg1` per iteration if `itemSize1` can shrink.
|
|
69
|
+
- **Confidence**: Confirmed
|
|
70
|
+
|
|
71
|
+
### GFXT-6 `sampler2d_compute_texel_value_conversion_scale_to_uint8` accumulates `max` from `min`
|
|
72
|
+
- **Severity**: MAJOR
|
|
73
|
+
- **File**: `engine/graphics/texture/sampler/sampler2d_compute_texel_value_conversion_scale_to_uint8.js:44`
|
|
74
|
+
- **Defect**: `max = max2(min, sampler2d_channel_compute_max(sampler, i).value)` uses `min` as the running accumulator, so the per-channel maxima of all but the last channel are discarded. The final `max` is just the last channel's maximum.
|
|
75
|
+
- **Failure scenario**: a `Float32` 2-channel sampler where channel 0 spans `[0, 1000]` and channel 1 spans `[0, 5]`. Iteration 0 sets `max = 1000`; iteration 1 overwrites it with `max2(0, 5) = 5`. `span = 5`, `scale = 51`. Every channel-0 value above 5 maps past 255 and saturates — the channel comes out uniformly white through `sampler2d_to_data_url`.
|
|
76
|
+
- **Fix**: `max = max2(max, sampler2d_channel_compute_max(sampler, i).value)`.
|
|
77
|
+
- **Confidence**: Confirmed
|
|
78
|
+
|
|
79
|
+
### GFXT-7 `sampler2d_to_html_canvas` adds the offset that its producer supplies as a value to subtract
|
|
80
|
+
- **Severity**: MAJOR
|
|
81
|
+
- **File**: `engine/graphics/texture/sampler/sampler2d_to_html_canvas.js:61,81` (producer: `sampler2d_compute_texel_value_conversion_scale_to_uint8.js:51`, composed in `sampler2d_to_data_url.js:16-18`)
|
|
82
|
+
- **Defect**: The canvas writer computes `(source_value + offset) * scale`, but the offset it is fed is `offset = min` and the scale is `255 / (max - min)`. Normalising to `[0,255]` requires `(v - min) * scale`. The two disagree in sign, so the mapping is only correct when `min === 0`.
|
|
83
|
+
- **Failure scenario**: `sampler2d_to_data_url(s)` on a `Float32` sampler holding `[10, 11, 12, 13]` (1 channel, 2×2). `min = 10`, `scale = 85`, `offset = 10`. Texel 0 becomes `(10 + 10) * 85 = 1700`, clamped to 255; every other texel likewise. The PNG is uniformly white instead of a four-step gradient. A sampler spanning `[-1, 1]` comes out uniformly black.
|
|
84
|
+
- **Fix**: return `offset: -min` from the producer (keeping the canvas writer's additive convention), or change the canvas writer to `(source_value - offset) * scale`. Pick one convention and apply it in both places.
|
|
85
|
+
- **Confidence**: Confirmed
|
|
86
|
+
|
|
87
|
+
### GFXT-8 `saturated_value_by_constructor` has no `Uint8ClampedArray` arm, so RGB→RGBA fills alpha with 1
|
|
88
|
+
- **Severity**: MAJOR
|
|
89
|
+
- **File**: `engine/graphics/texture/sampler/saturated_value_by_constructor.js:8-26` (consumer: `sampler2d_transfer_data.js:41,52`)
|
|
90
|
+
- **Defect**: The switch covers `Uint8Array` (255) but not `Uint8ClampedArray`, which is a distinct constructor and is not an `instanceof Uint8Array`. It falls through to `default: return 1`. `Sampler2D.uint8clamped()` is a first-class factory on the class and `ImageData.data` is always a `Uint8ClampedArray`.
|
|
91
|
+
- **Failure scenario**: `sampler2d_transfer_data(rgbSampler, Sampler2D.uint8clamped(4, 64, 64))` fills the alpha channel with `1` instead of `255`. Drawn to a canvas or uploaded as an RGBA texture, the image is ~0.4% opaque — effectively invisible.
|
|
92
|
+
- **Fix**: add `case Uint8ClampedArray: return 255;` (and, for completeness, `Float16Array` alongside the float arms).
|
|
93
|
+
- **Confidence**: Confirmed
|
|
94
|
+
|
|
95
|
+
### GFXT-9 `sampler2d_to_uint8_RGBA` treats Float16 and plain-array samplers as already-uint8
|
|
96
|
+
- **Severity**: MAJOR
|
|
97
|
+
- **File**: `engine/graphics/texture/sampler/sampler2d_to_uint8_RGBA.js:7-32,60-64`
|
|
98
|
+
- **Defect**: `compute_gradient_and_intercept_uint8` has arms for `Float32Array`/`Float64Array` (gradient 255) but none for `Float16Array` or a plain `Array`, both of which are supported `Sampler2D` storages (`Sampler2D.float16()`, `sampler2d_to_f16.js`, and the array branches in `clone`/`hash`/`computeByteSize`). They fall through to `default` — gradient 1, intercept 0 — which also satisfies the fast-path test on line 60.
|
|
99
|
+
- **Failure scenario**: `sampler2d_to_uint8_RGBA(Sampler2D.uint8(4,w,h), someFloat16RGBASampler)` with values in `[0,1]` takes the `output.data.set(input.data)` shortcut and writes 0 or 1 into each uint8 channel. A white image (1.0) becomes value 1 — effectively black. The same happens on the `VirtualTextureTileLoader.js:242` tile path for any non-uint8 tile source.
|
|
100
|
+
- **Fix**: add `case Float16Array:` next to the float arms (gradient 255), and derive the plain-array case from `sampler2d_compute_texel_value_conversion_scale_to_uint8`'s convention (it already treats a plain numeric array as Float32).
|
|
101
|
+
- **Confidence**: Confirmed
|
|
102
|
+
|
|
103
|
+
### GFXT-10 `Sampler2DSerializationAdapter` rejects `Uint8ClampedArray`-backed samplers
|
|
104
|
+
- **Severity**: MAJOR
|
|
105
|
+
- **File**: `engine/graphics/texture/sampler/serialization/Sampler2DSerializationAdapter.js:26`
|
|
106
|
+
- **Defect**: `if (value.data instanceof Uint8Array)` is false for a `Uint8ClampedArray`; the adapter then throws `TypeError: Unsupported data type`.
|
|
107
|
+
- **Failure scenario**: serialising any sampler produced by `Sampler2D.uint8clamped(...)`, `sampler2d_to_html_canvas` (line 31), `canvasDataToSampler`, or `html_canvas_to_sampler2d` (all `Uint8ClampedArray`) throws instead of writing the 8-bit path it clearly intends to cover. Deserialisation always produces a `Uint8Array`, so a clamped sampler cannot round-trip at all.
|
|
108
|
+
- **Fix**: test `value.data instanceof Uint8Array || value.data instanceof Uint8ClampedArray` (or use `compute_binary_data_type_from_typed_array`), and record which of the two to restore.
|
|
109
|
+
- **Confidence**: Confirmed
|
|
110
|
+
|
|
111
|
+
### GFXT-11 Binary/JSON serialisation of a `Sampler2D` ignores the view's `byteOffset`
|
|
112
|
+
- **Severity**: MAJOR
|
|
113
|
+
- **File**: `engine/graphics/texture/sampler/serialization/TextureBinaryBufferSerializer.js:102,114`; same defect in `Sampler2D.js:942` (`toJSON`)
|
|
114
|
+
- **Defect**: `serializeTexture` writes `new Uint8Array(array.buffer)` starting at byte 0 of the *underlying ArrayBuffer*, ignoring `array.byteOffset`; `toJSON` base64-encodes `this.data.buffer` wholesale for the same reason. When the sampler's data is a subarray view, the wrong bytes are serialised.
|
|
115
|
+
- **Failure scenario**: `SplatMapping.js:307` builds per-layer samplers as `new Sampler2D(this.weightData.subarray(startAddress, endAddress), 1, width, height)`. Serialising layer 2 of a 4-layer 256×256 splat map writes `width*height*1` bytes from offset 0 — i.e. layer 0's weights — and deserialising reproduces layer 0's data under layer 2's identity. `toJSON` additionally throws on an `Array`-backed sampler, since a plain array has no `.buffer`.
|
|
116
|
+
- **Fix**: `buffer.writeBytes(new Uint8Array(array.buffer, array.byteOffset, byteSize), 0, byteSize)`; in `toJSON`, encode `this.data.buffer.slice(byteOffset, byteOffset + byteLength)` (and handle plain arrays explicitly).
|
|
117
|
+
- **Confidence**: Confirmed
|
|
118
|
+
|
|
119
|
+
### GFXT-12 `Sampler2D.resize` crashes on an array-backed sampler
|
|
120
|
+
- **Severity**: MAJOR
|
|
121
|
+
- **File**: `engine/graphics/texture/sampler/Sampler2D.js:801-829`
|
|
122
|
+
- **Defect**: `resize` obtains a constructor via `typedArrayConstructorByInstance` (which returns `Array` for a plain array), allocates `new Array(length)` — a sparse array of holes — and in the width-preserving fast path calls `oldData.subarray(...)`, which plain arrays do not have. Plain `number[]` storage is explicitly supported everywhere else in the class (constructor default `data = []`, the `Array.isArray` branches in `clone`, `computeByteSize`, and `hash`).
|
|
123
|
+
- **Failure scenario**: `new Sampler2D([1,2,3,4], 1, 2, 2).resize(2, 4)` throws `TypeError: oldData.subarray is not a function`. Taking the other branch, `new Sampler2D([1,2,3,4], 1, 2, 2).resize(3, 3)` yields a sparse 9-slot array; `readChannel(2, 0, 0)` returns `undefined` instead of 0, and any arithmetic on it yields `NaN`.
|
|
124
|
+
- **Fix**: branch on `Array.isArray(oldData)` — allocate `new Array(length).fill(0)` and use `slice`/element copy instead of `subarray`.
|
|
125
|
+
- **Confidence**: Confirmed
|
|
126
|
+
|
|
127
|
+
### GFXT-13 `sampler2d_copy_rectangle` samples at pixel corners, so an identity copy blurs and shifts
|
|
128
|
+
- **Severity**: MAJOR
|
|
129
|
+
- **File**: `engine/graphics/texture/sampler/sampler2d_copy_rectangle.js:28,32`
|
|
130
|
+
- **Defect**: The source UV for output row `y` is `y * (v_span / target_height) + v0`, i.e. the *corner* of the output pixel. `sampleBilinearUV` then converts UV to texel space with `v * height - 0.5`, so the net sample position is half a destination pixel above/left of the pixel centre. The `+ 0.5` destination-pixel-centre term is missing.
|
|
131
|
+
- **Failure scenario**: `sampler2d_copy_rectangle(src, dst, 0, 0, 1, 1)` with `src` and `dst` both 4×4 (a straight full-rect copy) produces rows `[t0, (t0+t1)/2, (t1+t2)/2, (t2+t3)/2]` per column instead of `[t0, t1, t2, t3]`. The copy is blurred and shifted half a texel up-left, and the last row/column of the source is never fully reached.
|
|
132
|
+
- **Fix**: `const source_v = (y + 0.5) * v_multiplier + v0;` and `const source_u = (x + 0.5) * u_multiplier + u0;`.
|
|
133
|
+
- **Confidence**: Confirmed
|
|
134
|
+
|
|
135
|
+
### GFXT-14 `sampler2d_scale_down_mipmap` builds its mip chain from the width only
|
|
136
|
+
- **Severity**: MAJOR
|
|
137
|
+
- **File**: `engine/graphics/texture/sampler/resize/sampler2d_scale_down_mipmap.js:20`
|
|
138
|
+
- **Defect**: `while (current_mip.width > dest_width && current_mip.width > 1)` never consults the height. Each iteration halves *both* dimensions, so for a non-square source (or an aspect-changing resize) the chain stops based on X alone and Y is either over- or under-reduced.
|
|
139
|
+
- **Failure scenario**: source 256×64, destination 32×32. The loop runs three times (256→128→64→32), producing mips 128×32, 64×16, 32×8; `mip_mix = inverseLerp(64, 32, 32) = 1`, so the output is taken entirely from the 32×8 mip and then stretched back to 32 rows. Four vertical levels of detail are destroyed. In the reverse case — source 256×256, destination 256×8 — the loop body never executes, `inverseLerp(256, 256, 256)` returns 0 (its documented `range === 0` fallback), and the result is a raw bilinear resample of the full-resolution source with no prefiltering at all, so the vertical downscale aliases badly.
|
|
140
|
+
- **Fix**: drive the loop on both axes (`current_mip.width > dest_width || current_mip.height > dest_height`, clamping each halving at 1) and compute `mip_mix` from whichever axis is actually reducing.
|
|
141
|
+
- **Confidence**: Confirmed
|
|
142
|
+
|
|
143
|
+
### GFXT-15 `sampler2d_scale_down_lanczos` centres its kernel on a texel corner, not a texel centre
|
|
144
|
+
- **Severity**: MAJOR
|
|
145
|
+
- **File**: `engine/graphics/texture/sampler/resize/sampler2d_scale_down_lanczos.js:127,134`
|
|
146
|
+
- **Defect**: `center_x` is computed in continuous source space as `(u + 0.5) * ratio_x`, where source texel `i` has its centre at `i + 0.5`. The tap distance must therefore be `(i + 0.5) - center_x`, but the code uses `f_x = i - center_x` (likewise `f_y`). The near-identical `filter/sampler2d_scale_down_generic.js:63,70` gets this right (`(j - center_y) + 0.5`), which confirms the intent.
|
|
147
|
+
- **Failure scenario**: 512×512 → 256×256 (`ratio = 2`). For output pixel `u = 0`, `center_x = 1.0`. The correct tap distances to source texels 0 and 1 are `-0.5` and `+0.5` (symmetric); the code produces `-1.0` and `0.0`, centring the kernel on texel 1. Every output pixel is biased toward the higher-index source texel, so the whole downscaled image is shifted half a source texel up and left relative to a correct Lanczos resample.
|
|
148
|
+
- **Fix**: `const f_y = (j - center_y) + 0.5;` and `const f_x = (i - center_x) + 0.5;`, matching the generic downsampler.
|
|
149
|
+
- **Confidence**: Confirmed
|
|
150
|
+
|
|
151
|
+
### GFXT-16 Downsamplers discard negative filter lobes, silently substituting a different filter
|
|
152
|
+
- **Severity**: MAJOR
|
|
153
|
+
- **File**: `engine/graphics/texture/sampler/filter/sampler2d_scale_down_generic.js:80-82`; same code at `resize/sampler2d_scale_down_lanczos.js:144-146`
|
|
154
|
+
- **Defect**: `if (weight <= 0) { continue; }` skips the tap entirely and excludes it from the normalising sum `z`. That is harmless for strictly non-negative kernels (`box`, `triangle`, `gaussian`, `kaiser_1`) but silently truncates every windowed-sinc / BC-spline kernel to its positive lobes only.
|
|
155
|
+
- **Failure scenario**: `sampler2d_scale_down_generic(src, dst, filter_lanczos3, 3)` on a 512×512 → 128×128 downscale. `filter_lanczos3(1.5) = -0.135` and every tap with `|x| ∈ (1,2) ∪ (2,3)` is dropped, so the effective kernel is a positive-lobes-only blob normalised over its own positive weights. The result is a soft, Gaussian-like downscale with none of Lanczos-3's edge acutance — the caller asked for Lanczos and received something else. `mitchell` (which dips to about `-0.035` around `x = 1.5`) is affected the same way, as is the built-in `lanczos` with `lobes = 2`.
|
|
156
|
+
- **Fix**: only skip taps that are exactly zero (`if (weight === 0) continue;`) and accumulate negative weights into `z` normally.
|
|
157
|
+
- **Confidence**: Confirmed
|
|
158
|
+
|
|
159
|
+
### GFXT-17 `cubic2` evaluates the wrong polynomial in both branches
|
|
160
|
+
- **Severity**: MAJOR
|
|
161
|
+
- **File**: `engine/graphics/texture/sampler/filter/cubic2.js:17,21,25,29`
|
|
162
|
+
- **Defect**: Three separate errors. (a) Each term is written `Math.pow(coefficient * Math.abs(x), n)` — the coefficient is raised to the power along with `|x|` instead of multiplying `|x|^n`. (b) The two branches are swapped: the `|x| < 1` arm evaluates the `1 ≤ |x| < 2` polynomial and vice versa. (c) The `/6` normalisation is applied to only one term in one branch and omitted entirely in another. Compare `mitchell.js`, which implements the same BC-spline family correctly.
|
|
163
|
+
- **Failure scenario**: with `b = 0, c = 1`, `cubic2(0)` must be `(6 - 2b)/6 = 1`; it returns `pow(0,3) + pow(0,2) + 0 + 24 = 24`. `cubic2(1.5)` must be `-0.125`; it returns `pow(9,3) + pow(-18,2) + 6 = 1059`. Used as a weight function the filter produces weights three to four orders of magnitude off and of the wrong shape. Separately, `cubic2` never sets a `.support` property, so `sampler2d_scale_down_generic(input, output, cubic2)` (whose `support` parameter defaults to `weight_computer.support`) computes `range2_x = Math.ceil(ratio_x * undefined / 2) = NaN`, iterates zero taps, and divides by `z = 0` — every output texel becomes `NaN`.
|
|
164
|
+
- **Fix**: rewrite as `(A*|x|**3 + B*|x|**2 + C*|x| + D) / 6` with the standard BC-spline coefficients in the correct branches (mirror `mitchell.js`), and add `cubic2.support = 2;`.
|
|
165
|
+
- **Confidence**: Confirmed
|
|
166
|
+
|
|
167
|
+
### GFXT-30 `sampler2d_copy_with_margins` addresses the *source* with the *destination* channel count
|
|
168
|
+
- **Severity**: MAJOR
|
|
169
|
+
- **File**: `engine/graphics/texture/sampler/sampler2d_copy_with_margins.js:45,66,73,91,110,123,144,151`
|
|
170
|
+
- **Defect**: Every source column offset is computed as `… * dItemSize` (the destination's channel count). `sItemSize` is read on line 29 and then used only to derive `_itemSize` and `sRowSize`, never for source column addressing. The function is explicitly built to tolerate differing channel counts (`_itemSize = Math.min(dItemSize, sItemSize)`), so this is not an implicit precondition.
|
|
171
|
+
- **Failure scenario**: copying a 3-channel RGB patch into a 4-channel RGBA atlas. For the top-margin row, `sOffset = sA + (x + sourceX) * 4` addresses source element `4x` where element `3x` was meant. At `x = 3` it reads element 12 — texel 4's red — instead of element 9, texel 3's red; and because the read then takes 3 consecutive elements it splices `(R4, G4, B4)` where `(R3, G3, B3)` belonged. The margins are colour-shifted garbage sampled a third of a row too far right, and past `x = source.width * 3/4` they run off the end of the row into the next scanline. (The interior patch itself is correct — it goes through `destination.copy`, which uses `sItemSize`.)
|
|
172
|
+
- **Fix**: replace `dItemSize` with `sItemSize` in every source-offset expression (lines 45, 66, 73, 91, 110, 123, 144, 151); leave the destination offsets on `dItemSize`.
|
|
173
|
+
- **Confidence**: Confirmed
|
|
174
|
+
|
|
175
|
+
### GFXT-18 `TextureBinaryBufferSerializer` can write a Float16 texture it cannot read back
|
|
176
|
+
- **Severity**: MINOR
|
|
177
|
+
- **File**: `engine/graphics/texture/sampler/serialization/TextureBinaryBufferSerializer.js:37-72`
|
|
178
|
+
- **Defect**: `DataTypeIndices` assigns `Float16: 8` and `serializeTexture` happily writes it, but `readTypedArray`'s switch has no `BinaryDataType.Float16` arm, so it returns `null` and throws.
|
|
179
|
+
- **Failure scenario**: `serializeTexture(buf, Sampler2D.float16(1, 8, 8))` succeeds; `deserializeTexture(buf)` throws `TypeError: Unsupported data type float16`. The data is written to disk in a format that can never be loaded.
|
|
180
|
+
- **Fix**: add `case BinaryDataType.Float16: result = new Float16Array(length); buffer.readUint16Array(new Uint16Array(result.buffer), 0, length); break;` (or the matching typed reader), or reject Float16 at serialise time.
|
|
181
|
+
- **Confidence**: Confirmed
|
|
182
|
+
|
|
183
|
+
### GFXT-19 `computeSignedDistanceField_NaiveFlood` skips the top neighbours of cell (0,1)
|
|
184
|
+
- **Severity**: MINOR
|
|
185
|
+
- **File**: `engine/graphics/texture/sampler/distance/computeSignedDistanceField_NaiveFlood.js:38`
|
|
186
|
+
- **Defect**: `const top = index > width;` should be `index >= width`. Row 0 correctly has no top neighbours (`index` in `[0, width-1]`), but `index === width` — which is `(x=0, y=1)` — also fails the test even though it does have a row above it.
|
|
187
|
+
- **Failure scenario**: an 8×8 input whose only seed pixel is at (0,0). Cell (0,1) never sees its top-row neighbours, so it is not seeded from (0,0) or (1,0) during the initial open-set scan and is reached only later through (1,1). Its distance comes out one step larger than the correct value, and that error propagates down column 0.
|
|
188
|
+
- **Fix**: `const top = index >= width;`
|
|
189
|
+
- **Confidence**: Confirmed
|
|
190
|
+
|
|
191
|
+
### GFXT-20 `Sampler2D` constructor validates `width` twice and never validates `height`
|
|
192
|
+
- **Severity**: MINOR
|
|
193
|
+
- **File**: `engine/graphics/texture/sampler/Sampler2D.js:45`
|
|
194
|
+
- **Defect**: `if (!Number.isInteger(height) || width < 0)` — the second clause re-tests `width`. A negative integer `height` passes both this guard and the buffer-underflow check below it (the product `width * height * itemSize` goes negative, so `data.length < …` is trivially false).
|
|
195
|
+
- **Failure scenario**: `new Sampler2D(new Uint8Array(0), 1, 4, -1)` constructs successfully with `height = -1` instead of throwing `height must be a non-negative integer`. Downstream, `zeroFill`/`fill` iterate zero rows and `resize` allocates a `new Constructor(negative)`, which throws a `RangeError` far from the actual mistake.
|
|
196
|
+
- **Fix**: `if (!Number.isInteger(height) || height < 0)`.
|
|
197
|
+
- **Confidence**: Confirmed
|
|
198
|
+
|
|
199
|
+
### GFXT-21 `sampler2d_channel_compute_min_indices` seeds the result with index 0 rather than the channel offset
|
|
200
|
+
- **Severity**: MINOR
|
|
201
|
+
- **File**: `engine/graphics/texture/sampler/sampler2d_channel_compute_min_indices.js:36-40`
|
|
202
|
+
- **Defect**: `bestValue` is initialised from `data[channel]` (element index `channel`) but the corresponding reported index is hard-coded to `0`. Every index written later in the loop is a true element index `i`, so the seed is inconsistent with the rest of the output.
|
|
203
|
+
- **Failure scenario**: a 4-channel sampler where the minimum of channel 2 occurs at texel 0. The function returns count 1 with `result[0] = 0`, but the element address of that value is 2. A caller converting the returned element index back to a texel (`index / itemSize | 0`) gets the right texel by accident here, but a caller reading `data[result[0]]` gets channel 0's value instead of channel 2's.
|
|
204
|
+
- **Fix**: `result[result_offset] = channel;`
|
|
205
|
+
- **Confidence**: Confirmed
|
|
206
|
+
|
|
207
|
+
### GFXT-22 `sampler2d_to_texture` hands non-uint8 data to an `UnsignedByteType` texture
|
|
208
|
+
- **Severity**: MINOR
|
|
209
|
+
- **File**: `engine/graphics/texture/sampler/sampler2d_to_texture.js:20-33`
|
|
210
|
+
- **Defect**: When `sampler.itemSize === 4` the function skips conversion and references `sampler.data` directly, then unconditionally sets `result.type = UnsignedByteType` and `result.format = RGBAFormat`. The `else` branch, which allocates through `Sampler2D.uint8`, shows the function assumes 8-bit storage.
|
|
211
|
+
- **Failure scenario**: `sampler2d_to_texture(Sampler2D.float32(4, 64, 64))` yields a `DataTexture` whose `image.data` is a `Float32Array` but whose declared type is `UnsignedByteType`. WebGL's `texImage2D` with `UNSIGNED_BYTE` and a `Float32Array` raises `INVALID_OPERATION`; the texture uploads as black.
|
|
212
|
+
- **Fix**: take the conversion branch whenever the storage is not `Uint8Array`/`Uint8ClampedArray`, not only when `itemSize !== 4` (`sampler2d_ensure_uint8_RGBA` already implements exactly that test).
|
|
213
|
+
- **Confidence**: Confirmed
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## texture/atlas
|
|
218
|
+
|
|
219
|
+
### GFXT-23 `TextureAtlas.repack()` clears `Packed` twice and never clears `Painted`, so repacked patches are never redrawn
|
|
220
|
+
- **Severity**: CRITICAL
|
|
221
|
+
- **File**: `engine/graphics/texture/atlas/TextureAtlas.js:362`
|
|
222
|
+
- **Defect**: `patch.clearFlag(AtlasPatchFlag.Packed | AtlasPatchFlag.Packed)` ORs the same flag with itself; `AtlasPatchFlag.Painted` was clearly intended as the second operand. `repack()` then re-places every patch through `pack()` (which reorders by largest-min-side first, so placements do change), but `paint()` at line 497 only repaints a patch when `!getFlag(Painted) || last_painted_version !== sampler.version` — both false — so no pixels move.
|
|
223
|
+
- **Failure scenario**: an atlas holding patches A, B, C; remove B, then call `repack()`. A and C get new packing rectangles and new UVs, the pixel buffer is untouched, and no area is erased. A's UV rect now points at whatever pixels happen to occupy its new location (C's old image, or the erased hole left by B). Every repacked sprite renders the wrong texture until something else forces a version bump.
|
|
224
|
+
- **Fix**: `patch.clearFlag(AtlasPatchFlag.Packed | AtlasPatchFlag.Painted);` — and erase the vacated areas (or call `erase()`) before the re-pack so leftovers do not bleed into padding.
|
|
225
|
+
- **Confidence**: Confirmed
|
|
226
|
+
|
|
227
|
+
### GFXT-24 `AtlasLookupTexture.update()` never allocates storage for the first 32 patches
|
|
228
|
+
- **Severity**: CRITICAL
|
|
229
|
+
- **File**: `engine/graphics/texture/atlas/AtlasLookupTexture.js:58-65`
|
|
230
|
+
- **Defect**: The reallocation is gated solely on `image.height < expected_row_count`. The texture starts at 1×1 with a 4-element `Uint16Array`, and `expected_row_count = Math.ceil(patch_count / 32)` is also 1 for any patch count in `[1, 32]`, so the branch never fires and neither `image.width` (still 1) nor `image.data` (still 4 elements) is ever corrected.
|
|
231
|
+
- **Failure scenario**: attach an atlas holding 10 patches and call `update()`. The write loop runs for `i = 0..9` at addresses `0..39`, but the backing array has 4 slots — patches 1 through 9 are silently discarded (typed-array out-of-range writes are no-ops). The texture stays 1×1, so the shader can only ever read patch 0's rect and every other sprite samples the wrong region.
|
|
232
|
+
- **Fix**: gate on the full shape, e.g. `if (image.width !== rowSize || image.height < expected_row_count)`, and set `image.width`/`image.height`/`image.data` together.
|
|
233
|
+
- **Confidence**: Confirmed
|
|
234
|
+
|
|
235
|
+
### GFXT-25 `WebGLTextureAtlas.add()` never records the patch size, so every patch packs to a zero-area box at (0,0)
|
|
236
|
+
- **Severity**: CRITICAL
|
|
237
|
+
- **File**: `engine/graphics/texture/atlas/gpu/WebGLTextureAtlas.js:93-108` (also `:112`, `:178`)
|
|
238
|
+
- **Defect**: `add()` receives `target_width`/`target_height`, uses them only to accumulate `__total_area`, and never writes them into `patch.packing`; nothing ever calls `updatePositionFromPacking`, so `patch.position` also stays `(0,0)`. `layout()` therefore packs default (zero-extent) AABBs, and `build()` copies every source texture to `patch.position` — always the origin. Separately, `power = Math.ceil(Math.log2(this.__total_area))` treats an *area* as if it were a side length.
|
|
239
|
+
- **Failure scenario**: `add(tex, 0, 0, 64, 64, 64, 64)` for four textures, then `layout()` and `build(renderer)`. `__total_area = 16384`, so `power = 14` and the atlas is sized 16384×16384 (a ~1 GB `Uint8Array`) for four 64×64 tiles. All four packings remain zero-area at the origin, every `copyTextureToTexture` writes to `(0,0)`, and the four textures overwrite one another. The class cannot produce a usable atlas.
|
|
240
|
+
- **Fix**: in `add()`, `patch.size.set(target_width, target_height)` and `patch.packing.set(0, 0, target_width, target_height)`; after `layout()`, call `patch.updatePositionFromPacking(this.__width, this.__height)`; and size the atlas from `Math.ceil(Math.log2(Math.sqrt(this.__total_area)))`. Also index the source array by `patch.id` in `build()` (`this.__sources[atlas_patch.id]`) to match how `add()` stores it.
|
|
241
|
+
- **Confidence**: Confirmed
|
|
242
|
+
|
|
243
|
+
### GFXT-26 `AtlasLookupTexture` declares an RGB internal format for RGBA integer data
|
|
244
|
+
- **Severity**: MAJOR
|
|
245
|
+
- **File**: `engine/graphics/texture/atlas/AtlasLookupTexture.js:27-32`
|
|
246
|
+
- **Defect**: `texture.format = RGBAIntegerFormat` with four `Uint16` components per texel, but `texture.internalFormat = 'RGB16UI'`. GLES 3.0 requires format `RGB_INTEGER` for internal format `RGB16UI`; `RGBA_INTEGER` pairs only with `RGBA16UI`.
|
|
247
|
+
- **Failure scenario**: the first upload issues `texImage2D(TEXTURE_2D, 0, RGB16UI, w, h, 0, RGBA_INTEGER, UNSIGNED_SHORT, data)` → `GL_INVALID_OPERATION`. No texels are stored, so every `texelFetch` of the lookup table returns zeros and all patch rects resolve to `(0,0,0,0)`.
|
|
248
|
+
- **Fix**: `texture.internalFormat = 'RGBA16UI';`
|
|
249
|
+
- **Confidence**: Confirmed
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## texture/virtual
|
|
254
|
+
|
|
255
|
+
### GFXT-27 Changing `VirtualTexturePage.tile_resolution` leaves the residency capacity and tiles-per-row stale
|
|
256
|
+
- **Severity**: MAJOR
|
|
257
|
+
- **File**: `engine/graphics/texture/virtual/VirtualTexturePage.js:134-139` (consumed at `:263`, `:349-367`; driven from `VirtualTextureSystem.js:39`)
|
|
258
|
+
- **Defect**: The `tile_resolution` setter recomputes `#tile_slot_resolution` and drops residency, but never calls `#update_residency_capacity()`. `#residency_tile_capacity` and `#page_texture_resolution_in_tiles` are only refreshed by the `page_texture_size` setter, so they keep values derived from the previous slot resolution.
|
|
259
|
+
- **Failure scenario**: `system.initialize({page_resolution: 4096, …})` fixes capacity at `floor(4096/136)² = 900` slots (default tile 128 + 2×4 margin). A subsequent `system.setTexture({tile_resolution: 256, …})` raises the slot resolution to 264 — true capacity is `floor(4096/264)² = 225` — but capacity stays 900 and tiles-per-row stays 30. `#make_resident` then accepts slot 899 and computes `write_position = (29*264, 29*264) = (7656, 7656)`, far outside the 4096×4096 page, and the shader's `u_page_resolution` of 30 mis-maps every slot index to a page coordinate. The only in-repo call site (`virtual/prototype.js:225-230`) happens to call `setTexture` *before* `initialize`, which masks the bug.
|
|
260
|
+
- **Fix**: call `this.#update_residency_capacity()` at the end of the `tile_resolution` setter (and from `#update_tile_slot_resolution`).
|
|
261
|
+
- **Confidence**: Confirmed
|
|
262
|
+
|
|
263
|
+
### GFXT-28 `VirtualTextureSystem.setTexture` silently ignores its `tile_margin` argument
|
|
264
|
+
- **Severity**: MAJOR
|
|
265
|
+
- **File**: `engine/graphics/texture/virtual/VirtualTextureSystem.js:28-43` (`VirtualTexturePage` has a `tile_margin` getter at `:148` but no setter)
|
|
266
|
+
- **Defect**: `tile_margin` is destructured out of the options object and never used; `VirtualTexturePage.#tile_margin` is hard-wired to 4 and cannot be configured at all.
|
|
267
|
+
- **Failure scenario**: an asset pipeline that bakes 256 px tiles with an 8 px margin, configured as `setTexture({tile_resolution: 256, tile_margin: 8, …})`. The page still uses margin 4, so `#tile_slot_resolution` is 264 while each loaded tile is 272×272. In `#make_resident`, `tile_texture.image.data.set(tile.data.data)` copies 272²·4 values into a 264²·4 buffer and throws `RangeError: offset is out of bounds`; the page loads no tiles at all. The `assert.equal(tile.data.width, this.#tile_slot_resolution)` that would have diagnosed this is compiled out in production.
|
|
268
|
+
- **Fix**: add a `tile_margin` setter on `VirtualTexturePage` that updates `#tile_slot_resolution`, refreshes the residency capacity and drops residency, and have `setTexture` forward the argument.
|
|
269
|
+
- **Confidence**: Confirmed
|
|
270
|
+
|
|
271
|
+
### GFXT-31 `ResidencyDebugView` maps page slots to a grid using the tile size instead of the slot size
|
|
272
|
+
- **Severity**: MINOR
|
|
273
|
+
- **File**: `engine/graphics/texture/virtual/debug/ResidencyDebugView.js:27,38-45`
|
|
274
|
+
- **Defect**: `tile_columns = t.page_texture_resolution[0] / tile_resolution` divides by the tile resolution, but `VirtualTexturePage` lays slots out on a grid of `tile_resolution + 2 * tile_margin` and exposes the correct count as `page_texture_resolution_in_tiles[0]`. The draw also ignores the per-slot margin offset.
|
|
275
|
+
- **Failure scenario**: a 4096 px page with 128 px tiles and the default 4 px margin has 30 slot columns; the view computes 32. Slot 31, which physically lives at grid cell (1,1), is drawn at cell (31,0) — off the right edge of the visualisation. Every slot from 30 upward is drawn in the wrong cell, so the residency picture does not correspond to the page.
|
|
276
|
+
- **Fix**: use `t.page_texture_resolution_in_tiles[0]` for the column count and step by `tile_resolution + 2 * t.tile_margin`, offsetting the blit by `t.tile_margin`.
|
|
277
|
+
- **Confidence**: Confirmed
|
|
278
|
+
|
|
279
|
+
### GFXT-29 Virtual-texture fragment shader neither floors nor clamps the computed LOD
|
|
280
|
+
- **Severity**: MINOR
|
|
281
|
+
- **File**: `engine/graphics/texture/virtual/VirtualTextureMaterial.js:139-144` (`:109`)
|
|
282
|
+
- **Defect**: `float texture_lod_0 = float(texture_lod);` is a no-op cast where `floor(texture_lod)` was intended (the next line uses `ceil` for the upper level). Neither level is clamped to `[0, u_max_mip_level]` before `uint(...)` conversion and the `u_max_mip_level - texture_lod_0` subtraction.
|
|
283
|
+
- **Failure scenario**: a surface close enough to the camera that one screen pixel covers less than one virtual texel gives `texture_lod < 0`. `uint(-0.5)` is undefined in GLSL ES 3.0 and in practice yields a huge value, so `u_max_mip_level - texture_lod_0` underflows the unsigned subtraction and `1u << ui_texture_lod_0` shifts by an out-of-range amount — the mapping fetch lands on an arbitrary texel and the surface renders with a random tile. Symmetrically, a distant surface with `texture_lod > u_max_mip_level` underflows the same subtraction.
|
|
284
|
+
- **Fix**: `float texture_lod_0 = floor(texture_lod);` and clamp both levels: `clamp(texture_lod_0, 0.0, float(u_max_mip_level))` before converting to `uint`.
|
|
285
|
+
- **Confidence**: Likely (GL-side behaviour not executed here; the `float()`-for-`floor()` substitution itself is confirmed by inspection)
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## particles/particular — parameters, simulation, renderers, volumes
|
|
290
|
+
|
|
291
|
+
### GFXT-32 Billboard particle spec declares `color` as 1 component; the shader declares `vec4`
|
|
292
|
+
- **Severity**: CRITICAL
|
|
293
|
+
- **File**: `engine/graphics/particles/particular/engine/renderers/billboard/BILLBOARD_PARTICLE_SPECIFICATION.js:23` (shader at `renderers/billboard/ParticleBillboardMaterial.js:123`)
|
|
294
|
+
- **Defect**: The spec registers `color` with `itemSize: 1`, but the billboard shader it exists to feed declares `attribute vec4 color;`. The parallel spec for the same shader, `emitter/PARTICULAR_PARTICLE_SPECIFICATION.js:68-69`, correctly uses `itemSize: 4`.
|
|
295
|
+
- **Failure scenario**: two independent wrong outputs. (a) Rendering — a 1-component buffer bound to a `vec4` attribute has GL supply `(r, 0, 0, 1)`, so every particle renders pure red and fully opaque and per-particle alpha fade is dead (`vColor.a` is always 1). (b) Writing — `ParticlePool.writeAttributeVector4(i, …COLOR, r, g, b, a)` on an `itemSize: 1` attribute writes `array[i*1 + 0..3]`, overwriting the colours of particles `i+1, i+2, i+3`. Writing four particles in index order leaves particle 0 holding whatever particle 3 wrote.
|
|
296
|
+
- **Fix**: `itemSize: 4` for the `color` entry, matching `PARTICULAR_PARTICLE_SPECIFICATION`.
|
|
297
|
+
- **Confidence**: Confirmed
|
|
298
|
+
|
|
299
|
+
### GFXT-33 `ParticularEngine.advanceEmitter` collapses a layer's simulation steps into a set keyed by type
|
|
300
|
+
- **Severity**: MAJOR
|
|
301
|
+
- **File**: `engine/graphics/particles/particular/engine/ParticularEngine.js:155-167`
|
|
302
|
+
- **Defect**: Each step is dispatched through one shared object per `SimulationStepType` (`const step = steps[type]`) and a layer's parameters go into the single slot `step.layer_parameters[j]`. A second definition of the same type on the same layer overwrites the first, and the execution loop then runs each *type* once in numeric enum order, discarding the authored order.
|
|
303
|
+
- **Failure scenario**: `ParticleLayer.steps` is an ordered `List` filled straight from JSON, so expressing wind and gravity as two forces is natural — `[{FixedPhysics}, {ApplyForce, [5,0,0]}, {ApplyForce, [0,-9.8,0]}]`. `layer_parameters[j]` is set to `[5,0,0]` and immediately overwritten with `[0,-9.8,0]`; `ApplyForce.execute()` runs once. After one second a particle has `vx = 0` instead of `5` — the wind silently vanishes with no warning. Separately, an authored `[ApplyForce, FixedPhysics]` (semi-implicit Euler) executes as `[FixedPhysics, ApplyForce]` because `FixedPhysics` is enum value 0, so the first tick's gravity displacement is 0.
|
|
304
|
+
- **Fix**: iterate the layer's `steps` list in order and issue one step invocation per definition; at minimum reject duplicate types at build time rather than silently dropping them.
|
|
305
|
+
- **Confidence**: Confirmed (mechanism verified by inspection). Scope note: I enumerated every authored `steps` list in `app/data/meep-settings.json` — there are two, `["FixedPhysics","CurlNoiseVelocity"]` and `["FixedPhysics"]`, neither with a duplicate type and both already in enum order. So no shipped effect trips this today; it is a latent hazard for any effect that adds a second force of the same type, with no diagnostic when it does.
|
|
306
|
+
|
|
307
|
+
### GFXT-34 `ParameterLookupTable` writes its item count one byte too narrow at exact powers of two
|
|
308
|
+
- **Severity**: MAJOR
|
|
309
|
+
- **File**: `core/math/lookup/ParameterLookupTable.js:110` — reached through the in-scope re-export `particles/particular/engine/parameter/ParameterLookupTable.js`
|
|
310
|
+
- **Defect**: `const itemCountBitSize = Math.log2(itemCount)` is used as if it were the value's bit width. The width is `floor(log2(n)) + 1`, so at exactly `2^k` the size class picks a field one byte too small.
|
|
311
|
+
- **Failure scenario**: `RGB_LUT_TURBO` (in-repo, `parameter/sample/RGB_LUT_TURBO.js`) has exactly 256 entries. `toBinaryBuffer` computes `log2(256) === 8`, takes the `<= 8` branch, and calls `writeUint8(256)`, which truncates to 0 — then still writes 768 data floats plus 256 position floats. `fromBinaryBuffer` reads `itemCount = 0`, produces an empty LUT, and leaves the read cursor 4096 bytes short, so every subsequent field in the same buffer (the rest of the `ParticleParameter` / `ParameterTrack` / emitter payload) decodes as garbage. Same failure at 65536; 255, 257 and 65535 are fine.
|
|
312
|
+
- **Fix**: size the field from the value: `itemCountByteSize = itemCount <= 0xFF ? 1 : itemCount <= 0xFFFF ? 2 : 4;`
|
|
313
|
+
- **Confidence**: Confirmed
|
|
314
|
+
|
|
315
|
+
### GFXT-35 `ParameterLookupTable.addValue` does not recompute value statistics
|
|
316
|
+
- **Severity**: MAJOR
|
|
317
|
+
- **File**: `core/math/lookup/ParameterLookupTable.js:305` (end of `addValue`) — reached through the in-scope re-export
|
|
318
|
+
- **Defect**: `addValue` splices into `positions` and `data` but never calls `computeStatistics()`. Every other mutator (`write`, `fromJSON`, `fromBinaryBuffer`) does, so a table built purely with `addValue` keeps `valueMin = valueMax = 0`.
|
|
319
|
+
- **Failure scenario**: build a scale track programmatically — `lut.addValue(0, [1]); lut.addValue(1, [4]);` — and attach it as the `scale` track of a layer whose `particleSize.max` is 2. `ParticleLayer.computeScaledSpriteHalfSize()` (`emitter/ParticleLayer.js:165`) computes `maxParticleSize * lookupTable.valueMax = 2 * 0 = 0`, so the layer's `baseBoundingBox` gets no sprite padding: the emitter is frustum-culled and its BVH leaf is undersized while 8-unit-wide sprites are still on screen, and particles pop out at the screen edge. Secondarily, `hash()` short-circuits on `valueMin === valueMax`, so every `addValue`-built LUT hashes identically regardless of contents.
|
|
320
|
+
- **Fix**: call `this.computeStatistics()` at the end of `addValue`.
|
|
321
|
+
- **Confidence**: Confirmed
|
|
322
|
+
|
|
323
|
+
### GFXT-36 Barycentric point picking on a triangle is not uniform
|
|
324
|
+
- **Severity**: MAJOR
|
|
325
|
+
- **File**: `engine/graphics/particles/particular/engine/utils/distrubuteParticlesOnMesh.js:158`; same code at `engine/utils/distributeParticlesOnObject3D.js:215`
|
|
326
|
+
- **Defect**: `const r0 = random(); const r1 = random() * (1 - r0);` gives a joint density proportional to `1 / (1 - r0)` over the simplex instead of the constant required for uniformity. The cited reference is the *reflect* method (`if (r0 + r1 > 1) { r0 = 1 - r0; r1 = 1 - r1; }`), which is not what was implemented.
|
|
327
|
+
- **Failure scenario**: 2,000,000 samples on one triangle, binned by the barycentric weight on vertex B in deciles, come out flat at `0.100` per decile where the correct distribution is `0.190, 0.170, 0.150, 0.130, 0.110, 0.090, 0.070, 0.050, 0.030, 0.010`. Density is about 10x too high in the decile nearest vertex B and about half the correct density near vertex A. Seeding a 2-triangle 10x10 ground quad with 5000 particles visibly piles them along the shared diagonal and thins the two opposite corners.
|
|
328
|
+
- **Fix**: draw two independent uniforms and reflect, or use `su0 = Math.sqrt(random()); r0 = 1 - su0; r1 = random() * su0;`.
|
|
329
|
+
- **Confidence**: Confirmed
|
|
330
|
+
|
|
331
|
+
### GFXT-37 `ParticleVolume.fromJSON` logs an unknown attribute and then dereferences it
|
|
332
|
+
- **Severity**: MAJOR
|
|
333
|
+
- **File**: `engine/graphics/particles/particular/engine/utils/volume/ParticleVolume.js:353-359`
|
|
334
|
+
- **Defect**: The `attribute_object === undefined` branch warns "skipping" but has no `continue`; execution falls straight through to `attribute_object.fromJSON(attribute_json)`.
|
|
335
|
+
- **Failure scenario**: only `color`, `size` and `blending` are in `EXPOSED_ATTRIBUTES`. Loading `{ "density": 2, "shape": {…}, "attributes": { "velocity": { "value": [0,1,0] } } }` logs `No 'velocity' attribute, skipping` and then throws `TypeError: Cannot read properties of undefined (reading 'fromJSON')`, aborting the entire scene load instead of skipping one attribute.
|
|
336
|
+
- **Fix**: add `continue;` inside the `if`.
|
|
337
|
+
- **Confidence**: Confirmed
|
|
338
|
+
|
|
339
|
+
### GFXT-38 `lut_to_sampler` clobbers the alpha channel of RGBA lookup tables
|
|
340
|
+
- **Severity**: MINOR
|
|
341
|
+
- **File**: `engine/graphics/particles/particular/engine/utils/volume/lut_to_sampler.js:18`
|
|
342
|
+
- **Defect**: `s[3] = 255` runs unconditionally after `lut.sample(...)`, so an `itemSize === 4` LUT's authored alpha is discarded. The line is only correct for the RGB (`itemSize === 3`) case.
|
|
343
|
+
- **Failure scenario**: `lut_to_sampler(RGBA_LUT_HEATMAP_IR, 64, 4)` — the in-repo LUT authors alpha `10` at every key. `sample` writes `s[3] = 10`; the next line overwrites it with `255`. Every texel comes out fully opaque, so the sampler renders as a solid heat ramp instead of a ~4%-opacity overlay.
|
|
344
|
+
- **Fix**: `if (lut.itemSize < 4) { s[3] = 255; }`. (Same line, secondary: `width === 1` makes `i / (width - 1)` be `NaN`, and `sample` then falls through its search loop and returns the last key rather than the first.)
|
|
345
|
+
- **Confidence**: Confirmed
|
|
346
|
+
|
|
347
|
+
### GFXT-39 `ParticleVolume.build` hard-wires the global unseeded RNG
|
|
348
|
+
- **Severity**: MINOR
|
|
349
|
+
- **File**: `engine/graphics/particles/particular/engine/utils/volume/ParticleVolume.js:599`
|
|
350
|
+
- **Defect**: `__distribute` is explicitly parameterised on a `random` function — and both sibling utilities take an explicit seed — but `build()` passes `Math.random`.
|
|
351
|
+
- **Failure scenario**: a scene holding a `ParticleVolume` with `density: 5` over a 10 m³ shape (50 particles) produces different particle positions on every load, and two clients viewing the same serialized scene see different smoke. The volume is not reproducible from its own serialized form.
|
|
352
|
+
- **Fix**: accept a seed on `build()`/`fromJSON` and pass `seededRandom(seed)`, matching the mesh distributors.
|
|
353
|
+
- **Confidence**: Confirmed
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## geometry — buffered attributes, BVH queries, skinning
|
|
358
|
+
|
|
359
|
+
### GFXT-40 `computeMeshSurfaceArea` reads vertex C's Y from the Z slot
|
|
360
|
+
- **Severity**: CRITICAL
|
|
361
|
+
- **File**: `engine/graphics/geometry/computeMeshSurfaceArea.js:40`
|
|
362
|
+
- **Defect**: `const y2 = points[c3 + 2];` — the third vertex is assembled as `(cx, cz, cz)`, so every triangle area in the mesh is computed against a corrupted vertex. (Found independently by two reviewers.)
|
|
363
|
+
- **Failure scenario**: any mesh lying in the XY plane, e.g. the single triangle `A=(0,0,0)`, `B=(1,0,0)`, `C=(0,1,0)` whose area is `0.5`. C is read as `(0,0,0) === A`, so `tri3_area` returns 0 and the total is 0. `distributeParticlesOnMesh` then computes `surfaceSpacing = 0` → `invSurfaceSpacing = Infinity` → `placementCount = 0 * Infinity = NaN` → `for (j = 0; j < NaN; …)` never runs, so **zero particles are emitted** and the pool keeps whatever positions it had. For non-planar meshes the per-triangle area weights are simply wrong, so the area-weighted distribution is biased.
|
|
364
|
+
- **Fix**: `const y2 = points[c3 + 1];`
|
|
365
|
+
- **Confidence**: Confirmed
|
|
366
|
+
|
|
367
|
+
### GFXT-41 `bvh32_geometry_raycast` reports a hit it never wrote
|
|
368
|
+
- **Severity**: MAJOR
|
|
369
|
+
- **File**: `engine/graphics/geometry/buffered/query/bvh32_geometry_raycast.js:124`
|
|
370
|
+
- **Defect**: `hit_found = true` is set as soon as a triangle intersects, before the `best_distance_sqr` test that decides whether `result` is written. If every intersection lies beyond `max_distance`, the function returns `true` with `result` untouched.
|
|
371
|
+
- **Failure scenario**: the broadphase `bvh32_query_user_data_ray` prunes on AABB *entry* distance, so a triangle whose box is entered early but whose surface is hit late survives the cull. Triangle `A=(0,0,0)`, `B=(10,0,20)`, `C=(0,10,20)` (AABB `(0,0,0)-(10,10,20)`), ray origin `(1,1,-1)`, direction `(0,0,1)`, `max_distance = 4`: AABB entry `t = 1 < 4` so the triangle is reported, but the surface hit is at `(1,1,4)`, distance 5. `distance_sqr = 25 >= 16`, so `result` is never written — yet the function returns `true` and the caller reads a stale `SurfacePoint3` from the previous query.
|
|
372
|
+
- **Fix**: move `hit_found = true;` inside the `if (distance_sqr < best_distance_sqr)` block.
|
|
373
|
+
- **Confidence**: Confirmed
|
|
374
|
+
|
|
375
|
+
### GFXT-42 `decode_attribute_value` throws on every 8-bit and signed normalized type
|
|
376
|
+
- **Severity**: MAJOR
|
|
377
|
+
- **File**: `engine/graphics/geometry/decode_attribute_value.js:17-27`
|
|
378
|
+
- **Defect**: The denormalization switch handles only `Float32`, `Float64`, `Uint16` and `Uint32`; `Uint8`, `Int8`, `Int16` and `Int32` fall through to `throw`. The sibling `core/collection/array/typed/typed_array_value_denormalize.js` already carries the full, correct table.
|
|
379
|
+
- **Failure scenario**: glTF requires `COLOR_0` as unsigned byte to be `normalized: true`, and `GLTFLoader` produces a `Uint8Array` `BufferAttribute` accordingly. `sh3/path_tracer/texture/sample_material.js:37-42` derives `BinaryDataType.Uint8` from that array and calls `decode_attribute_value(v, Uint8, true)`, which throws `Unsupported data type uint8` and aborts the path-tracer sample for that surface. The same happens for any `KHR_mesh_quantization` asset (`Int8`/`Int16`).
|
|
380
|
+
- **Fix**: add the missing arms mirroring `typed_array_value_denormalize` — `Uint8 → /255`, `Int8 → max(v/127, -1)`, `Int16 → max(v/32767, -1)`, `Int32 → max(v/2147483647, -1)`.
|
|
381
|
+
- **Confidence**: Confirmed
|
|
382
|
+
|
|
383
|
+
### GFXT-43 `computeSkinnedMeshVertices` reads attributes as raw `.array` with a hard-coded stride
|
|
384
|
+
- **Severity**: MAJOR
|
|
385
|
+
- **File**: `engine/graphics/geometry/skining/computeSkinnedMeshVertices.js:24-26`
|
|
386
|
+
- **Defect**: `attributes.position.array` / `skinIndex.array` / `skinWeight.array` are read directly and then indexed with a fixed stride of 3 or 4, ignoring both interleaving and the `normalized` flag. In three r136 `InterleavedBufferAttribute.array` is a getter returning `this.data.array` — the *whole* interleaved buffer.
|
|
387
|
+
- **Failure scenario**: a glTF skinned mesh with an interleaved vertex buffer (POSITION + NORMAL + TEXCOORD_0, stride 8 floats). `positions[index * 3]` walks three floats at a time through position, normal and UV data, so `mesh_apply_skeletal_vertex_skinning` produces completely scrambled vertices. Second case: gltfpack / `KHR_mesh_quantization` output where `WEIGHTS_0` is normalized `Uint8` — a weight is read as `255` instead of `1.0`, scaling the skinned position by 255x. Both feed `computeSkinnedMeshBoundingVolumes` (used by `GLTFAssetLoader.js:307`), `meshFromSkinnedMesh`, and the particle distributors.
|
|
388
|
+
- **Fix**: route the three attributes through the existing `buffered/buffer_attribute_deinterleave.js` + `buffered/buffer_attribute_denormalize.js` before reading `.array`, or pass real `offset`/`stride`/`normalized` into `mesh_apply_skeletal_vertex_skinning`.
|
|
389
|
+
- **Confidence**: Confirmed
|
|
390
|
+
|
|
391
|
+
### GFXT-44 `computeGeometryBoundingSphereMiniball` builds its point set from the raw attribute array
|
|
392
|
+
- **Severity**: MAJOR
|
|
393
|
+
- **File**: `engine/graphics/geometry/buffered/computeGeometryBoundingSphereMiniball.js:10`
|
|
394
|
+
- **Defect**: `const vertexData = geometry.attributes.position.array;` followed by a point count of `positions.length / 3`. As above, for an interleaved attribute that array is the entire interleaved buffer, and for a quantized attribute the values are un-denormalized.
|
|
395
|
+
- **Failure scenario**: interleaved glTF geometry (position + normal + uv, stride 8) makes `PointSet(len/3, 3, wholeBuffer)` fold normal and UV components into the point cloud, so the sphere centre and radius are computed over garbage. Quantized `Int16` normalized positions give a radius roughly 32767x the true size. `ensureGeometryBoundingSphere` → this function is what `GLTFAssetLoader.__processMesh` uses for every non-skinned mesh, so the result is broken frustum culling — meshes popping out of view, or never culling at all.
|
|
396
|
+
- **Fix**: deinterleave and denormalize first, and drive the loop from `attribute.count` rather than `array.length`.
|
|
397
|
+
- **Confidence**: Confirmed
|
|
398
|
+
|
|
399
|
+
### GFXT-45 Degenerate triangles inject a fabricated up-normal into vertex-normal accumulation
|
|
400
|
+
- **Severity**: MAJOR
|
|
401
|
+
- **File**: `engine/graphics/geometry/buffered/geometry_compute_vertex_normals_indexed.js:28-37`
|
|
402
|
+
- **Defect**: `geometry_construct_triangle_normal` delegates to `tri3_compute_normal`, which writes the sentinel `(0, 1, 0)` when the cross product has zero length. This loop adds that full-magnitude unit vector into all three vertices' accumulated normals instead of contributing nothing.
|
|
403
|
+
- **Failure scenario**: a mesh with a collapsed triangle — indices `[0,1,2, 0,1,1]`, where 0/1/2 form a flat face in the XY plane with normal `(0,0,1)`. Vertex 0 accumulates `(0,0,1)` from the good triangle plus `(0,1,0)` from the degenerate one; after `v3_array_normalize_many` its normal is `(0, 0.707, 0.707)` instead of `(0,0,1)`. Shading is visibly wrong along every collapsed edge — routine in LOD/simplified meshes and n-gon triangulation output.
|
|
404
|
+
- **Fix**: accumulate the raw unnormalized cross product in this loop (which contributes zero for a degenerate face and also gives three.js-compatible area weighting), or have `geometry_construct_triangle_normal` signal degeneracy and `continue` on it.
|
|
405
|
+
- **Confidence**: Confirmed
|
|
406
|
+
|
|
407
|
+
### GFXT-46 `meshFromSkinnedMesh` applies the world transform twice
|
|
408
|
+
- **Severity**: MAJOR
|
|
409
|
+
- **File**: `engine/graphics/geometry/skining/meshFromSkinnedMesh.js:33-36`
|
|
410
|
+
- **Defect**: `computeSkinnedMeshVertices` produces `bindMatrixInverse · Σ(w·boneMatrix) · bindMatrix · p`, which is exactly what three's skinning shader hands to `modelMatrix` — i.e. mesh-local space by construction. The function nonetheless bakes `inverse(matrixWorld)` into those vertices while the new `Mesh` separately copies the source's local TRS, so the transform is applied once forward and once inverted.
|
|
411
|
+
- **Failure scenario**: a `SkinnedMesh` at `position = (0,5,0)` with no parent, in rest pose. `V = p`. `mesh.position` is copied as `(0,5,0)` and the geometry is baked to `p - (0,5,0)`, so the result renders at `translate(0,5,0) · (p - (0,5,0)) = p` while the original renders at `p + (0,5,0)`. The exported mesh is displaced by the full world transform; the bug is invisible only when `matrixWorld` is identity. Reachable from `model/game/options/exportSceneAsGLTF.js:166,181`.
|
|
412
|
+
- **Fix**: drop the `applyMatrix4(m4)` call (and the `updateMatrix`/`updateMatrixWorld` that exist only to feed it); the copied TRS already reproduces the source placement.
|
|
413
|
+
- **Confidence**: Likely
|
|
414
|
+
|
|
415
|
+
### GFXT-47 `compute_buffer_geometry_byte_size` charges every attribute the size of its whole backing buffer
|
|
416
|
+
- **Severity**: MINOR
|
|
417
|
+
- **File**: `engine/graphics/geometry/buffered/compute_buffer_geometry_byte_size.js:12,16`
|
|
418
|
+
- **Defect**: Uses `array.buffer.byteLength` (the whole `ArrayBuffer`) rather than `array.byteLength` (the view). Attributes that are views into one shared buffer are each charged the full buffer.
|
|
419
|
+
- **Failure scenario**: a glTF geometry with position, normal and uv interleaved in one 1 MB `ArrayBuffer` reports about 3 MB, plus the index buffer's own full backing buffer. Any cache sized by this number — the pattern `GeometrySpatialQueryAccelerator` uses for its `valueWeigher` — evicts several times too aggressively.
|
|
420
|
+
- **Fix**: `result += attribute.array.byteLength;`
|
|
421
|
+
- **Confidence**: Confirmed
|
|
422
|
+
|
|
423
|
+
### GFXT-48 `bvh32_geometry_raycast` returns an unset `index` and an unnormalized normal
|
|
424
|
+
- **Severity**: MINOR
|
|
425
|
+
- **File**: `engine/graphics/geometry/buffered/query/bvh32_geometry_raycast.js:59,126-131`
|
|
426
|
+
- **Defect**: `triangle_index` is in hand at line 59 but never assigned to the scratch hit, so `result.copy(scratch_hit)` overwrites `result.index` with the module scratch's permanent `-1`. `result.normal` is the raw cross product produced by `tri3_ray_intersection`, not a unit vector.
|
|
427
|
+
- **Failure scenario**: raycasting the triangle `A=(0,0,0)`, `B=(10,0,0)`, `C=(0,10,0)` returns `normal = (0,0,100)` (length `2·area`) and `index = -1`. The sibling `bvh32_geometry_nearest` over the same geometry returns a unit normal and a valid triangle index, so the two query entry points disagree on their output contract. Consumers that route through `SurfacePoint3.applyMatrix4` are masked because `applyDirectionMatrix4` renormalizes.
|
|
428
|
+
- **Fix**: set `scratch_hit.index = triangle_index;` before the copy, and normalize the normal (root cause is `core/geom/3d/triangle/tri3_ray_intersection.js:146`).
|
|
429
|
+
- **Confidence**: Confirmed
|
|
430
|
+
|
|
431
|
+
### GFXT-49 `meshFromSkinnedMesh` keeps the source geometry's bind-pose bounding volumes
|
|
432
|
+
- **Severity**: MINOR
|
|
433
|
+
- **File**: `engine/graphics/geometry/skining/meshFromSkinnedMesh.js:13-21`
|
|
434
|
+
- **Defect**: `geometry.clone()` carries `boundingBox` and `boundingSphere` across (r136 `BufferGeometry.copy` clones both when non-null), and the position array is then overwritten with skinned vertices without invalidating them.
|
|
435
|
+
- **Failure scenario**: a humanoid whose source geometry already has bounds — always the case after `GLTFAssetLoader.__processMesh` — baked while crouched or mid-jump keeps the bind-pose sphere. `WebGLRenderer` frustum-culls the result against bounds that no longer enclose the geometry, so the baked mesh pops out of view at screen edges.
|
|
436
|
+
- **Fix**: null out `bakedGeometry.boundingBox` and `bakedGeometry.boundingSphere` after writing the skinned positions, or recompute them.
|
|
437
|
+
- **Confidence**: Confirmed
|
|
438
|
+
|
|
439
|
+
### GFXT-50 `aabb3_to_buffer_geometry` winds its top face inward (and the module exports nothing)
|
|
440
|
+
- **Severity**: MINOR
|
|
441
|
+
- **File**: `engine/graphics/geometry/aabb3_to_buffer_geometry.js:8,19-22`
|
|
442
|
+
- **Defect**: The top quad repeats the bottom quad's winding, so its face normal points down instead of up. Separately the function declaration lacks `export`, so the module has no exports at all and no importer can currently reach it — which is why the winding error has gone unnoticed.
|
|
443
|
+
- **Failure scenario**: `AABB3.getCorners` emits corners in the order `(x0y0z0, x1y0z0, x0y1z0, x1y1z0, x0y0z1, x1y0z1, x0y1z1, x1y1z1)`. Bottom triangle `0→1→5` yields normal `(0, -dx·dz, 0)` = −Y, correctly outward. Top triangle `2→3→7` yields the same `(0, -dx·dz, 0)` = −Y, i.e. inward. With any default `FrontSide` material the top face of the box is invisible from above.
|
|
444
|
+
- **Fix**: add `export` to the declaration and reverse the top winding to `2, 7, 3, 7, 2, 6`.
|
|
445
|
+
- **Confidence**: Confirmed
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## particles/particular — pool, group, emitter
|
|
450
|
+
|
|
451
|
+
### GFXT-51 `ParticleGroup.deleteIndices` reuses the same swap source for every deletion
|
|
452
|
+
- **Severity**: CRITICAL
|
|
453
|
+
- **File**: `engine/graphics/particles/particular/group/ParticleGroup.js:478-492`
|
|
454
|
+
- **Defect**: The swap-source cursor `swapDestination` is decremented only when it happens to equal the current victim, never after it has actually been consumed as a swap source. Every swap in a multi-delete therefore copies from the *same* tail slot, duplicating one particle and losing the others.
|
|
455
|
+
- **Failure scenario**: reproduced under jest against the real class. Four particles with a scalar attribute holding `[10, 11, 12, 13]`; `group.deleteIndices([0, 1])` — equivalently two `group.remove(ref)` calls in one frame, which `groupOperations` merges into a single `Remove` with two operands. Expected survivors `[12, 13]`; the actual buffer after the call is `[13, 13]` — particle `12` is gone and `13` is rendered twice. `indexReferenceLookup`/`referenceIndexLookup` are also rewritten so two live references point at the same data slot. Any batch of two or more removals that produces two or more real swaps corrupts the group. The existing spec cases (`[1]`, `[0,2,4]`, `[1,9,10,11]`) each happen to generate exactly one swap, which is why this is untested.
|
|
456
|
+
- **Fix**: consume the source cursor and skip slots that are themselves being deleted — iterate victims ascending, advancing a tail pointer past deleted destinations, and decrement `swapDestination` after each push. A bare `swapDestination--` after the push is *not* sufficient: `indices = [0,1,3]` with `size = 5` would then copy deleted slot 3 into a survivor slot.
|
|
457
|
+
- **Confidence**: Confirmed (reproduced against the real `ParticleGroup`)
|
|
458
|
+
|
|
459
|
+
### GFXT-52 `optimizeCommandQueue` sort comparator never returns `+1`, so writes can execute before their `Add`
|
|
460
|
+
- **Severity**: CRITICAL
|
|
461
|
+
- **File**: `engine/graphics/particles/particular/group/optimizeCommandQueue.js:141`
|
|
462
|
+
- **Defect**: `if (operatorA < operatorB) return -1; else if (operatorB > operatorA) return 1;` — the second condition is a restatement of the first, not its inverse, so the operator-ordering branch can never return `+1`. When `a` is a `WriteAttribute` and `b` is an `Add`/`Remove`, control falls through to the attribute-index arithmetic and compares a *reference id* against an *attribute index*, breaking the ordering invariant stated in the comment two lines above.
|
|
463
|
+
- **Failure scenario**: reproduced under jest. A group with 4 attributes and one existing particle (ref 0 at index 0, attribute 3 = `200`), then `r1 = create(); r2 = create(); writeAttribute(r1, 3, [11]); writeAttribute(r2, 3, [22]); update();`. `groupOperations` merges the adds into `Add[1,2]`; the comparator evaluates `b.operands[1] - a.operands[1] = 2 - 3 = -1` and sorts the writes first. Observed order: `Write(1,3) | Write(2,3) | Add[1,2]`. Both writes dereference to `undefined`, so `attribute.array.set(value, undefined * itemSize)` is `set(value, NaN)`, which coerces to offset **0** and silently overwrites the existing particle. Attribute 3 ends up `[22, 0, 0]`: the pre-existing `200` is clobbered and both new particles get `0`. This triggers whenever a grouped `Add` has two or more operands and its second reference id is below the write's attribute index — routine, since `IdPool` hands out ids from 0 and `PARTICULAR_PARTICLE_SPECIFICATION` has 12 attributes.
|
|
464
|
+
- **Fix**: `} else if (operatorA > operatorB) { return 1; }`
|
|
465
|
+
- **Confidence**: Confirmed (reproduced against the real `ParticleGroup`)
|
|
466
|
+
|
|
467
|
+
### GFXT-53 `ParticleGroup.build()` leaves the geometry draw range at `Infinity`
|
|
468
|
+
- **Severity**: MAJOR
|
|
469
|
+
- **File**: `engine/graphics/particles/particular/group/ParticleGroup.js:101`
|
|
470
|
+
- **Defect**: `build()` creates a fresh `BufferGeometry`, whose three.js default `drawRange` is `{start: 0, count: Infinity}`, and never calls `setDrawRange`. Only `updateCapacity()` sets it, and neither the constructor nor `reset()` reaches that path. `build()` additionally copies the *old* attribute data forward unconditionally. The sibling `ParticlePool.build()` does call `setDrawRange` (line 280).
|
|
471
|
+
- **Failure scenario**: reproduced under jest. (a) `new ParticleGroup(spec)` gives `size = 0`, `capacity = 100`, `drawRange = {start: 0, count: Infinity}`, so the renderer draws `min(position.count, Infinity) = 100` vertices from zero-filled buffers — exactly the state `view/minimap/gl/MinimapMarkersGL.js:128` renders in (with `frustumCulled = false`) before the first marker is added. (b) Add 5 particles with attribute `a = [7,7]`, `update()` (draw range correctly becomes 5), then `reset()` → the count is `Infinity` again *and* the attribute array still reads `[7,7,7,7,7,7,7,7,7,7,…]`, so the group renders 100 vertices whose first 5 are the supposedly-cleared particles. `MinimapMarkersGL.shutdown()` takes exactly this path.
|
|
472
|
+
- **Fix**: end `build()` with `geometry.setDrawRange(0, this.size);`, and skip the old-data copy when `this.size === 0`.
|
|
473
|
+
- **Confidence**: Confirmed (reproduced against the real `ParticleGroup`)
|
|
474
|
+
|
|
475
|
+
### GFXT-54 `ParticleGroup.preAllocate` advertises reserved capacity as live particles
|
|
476
|
+
- **Severity**: MAJOR
|
|
477
|
+
- **File**: `engine/graphics/particles/particular/group/ParticleGroup.js:601`
|
|
478
|
+
- **Defect**: `preAllocate` routes a *capacity* request through `updateCapacity`, whose tail unconditionally calls `geometry.setDrawRange(0, newSize)`. Reserving space therefore also tells the renderer that the reserved slots contain particles.
|
|
479
|
+
- **Failure scenario**: reproduced under jest. A group with 5 live particles (`size = 5`, `capacity = 5`, `drawRange.count = 5`); `group.preAllocate(500)` gives `capacity = 505` and `size` still `5`, but `drawRange.count = 505`. The renderer draws 500 uninitialised/stale vertices alongside the 5 real particles until the next `create()` + `update()` happens to reset the range.
|
|
480
|
+
- **Fix**: have `preAllocate` call `setCapacity(...)` (or a capacity-only helper) and leave the draw range alone — the draw range belongs to `setSize`, not to capacity management.
|
|
481
|
+
- **Confidence**: Confirmed (reproduced against the real `ParticleGroup`)
|
|
482
|
+
|
|
483
|
+
### GFXT-55 `SERIALIZABLE_FLAGS` omits `Lit`, so `receiveLight` is dropped by binary serialization
|
|
484
|
+
- **Severity**: MAJOR
|
|
485
|
+
- **File**: `engine/graphics/particles/particular/engine/emitter/ParticleEmitter.js:46` (consumed at `serde/ParticleEmitterSerializationAdapter.js:23,98-101`)
|
|
486
|
+
- **Defect**: `SERIALIZABLE_FLAGS` lists `PreWarm | DepthSorting | DepthReadDisabled | DepthSoftDisabled | AlignOnVelocity` but not `ParticleEmitterFlag.Lit`, even though `Lit` is an authored property (`toJSON`/`fromJSON` persist it as `receiveLight`, `ParticleVolume` writes it, and `ShaderManager` compiles a different shader for it). The adapter masks with `SERIALIZABLE_FLAGS` on both write and read, so the bit is silently discarded.
|
|
487
|
+
- **Failure scenario**: author an emitter with `receiveLight: true` (`ParticleVolume.js:296` calls `emitter.writeFlag(ParticleEmitterFlag.Lit, true)`), save the level through `ParticleEmitterSerializationAdapter.serialize`, and reload. `deserialize` neither restores nor clears `Lit`, and a fresh `ParticleEmitter` defaults it to false, so `ShaderManager.buildMaterial` (line 251) builds `make_particle_billboard_shader({ lights: false })` and the particles render unlit. Secondarily, `hash()`/`equals()` also use `SERIALIZABLE_FLAGS`, so a lit and an unlit emitter compare equal and can share a cached material record — note `EMITTER_MATERIAL_FLAGS` in `ShaderManager.js:20` *does* include `Lit`, which shows the intent.
|
|
488
|
+
- **Fix**: add `| ParticleEmitterFlag.Lit` to `SERIALIZABLE_FLAGS` and bump the adapter version (the field width is unchanged, so a pass-through upgrader suffices).
|
|
489
|
+
- **Confidence**: Confirmed
|
|
490
|
+
|
|
491
|
+
### GFXT-56 Sphere emission shapes get a half-size layer bounding box
|
|
492
|
+
- **Severity**: MAJOR
|
|
493
|
+
- **File**: `engine/graphics/particles/particular/engine/emitter/ParticleLayer.js:206`
|
|
494
|
+
- **Defect**: `computePointBoundingBox` expands the layer bounds by `0.5 * scale` for every non-`Point` emission shape. That factor is correct only for `EmissionShapeType.Box`, whose `random_point_in_box` samples `[-0.5, 0.5]`; `random_point_in_sphere` / `random_point_on_sphere` are unit-**radius** (extent `[-1, 1]`), and the spawn path applies the full scale (`ParticleEmitter.js:1023`, `v3position.multiply(layer.scale)`).
|
|
495
|
+
- **Failure scenario**: a layer with `emissionShape = Sphere`, `emissionFrom = Volume`, `scale = (4,4,4)`, `particleSpeed = (0,0)`, `position = (0,0,0)`. Particles occupy a radius-4 ball, but `computePointBoundingBox` returns `[-2,-2,-2 .. 2,2,2]`. `computeEmissionBounds` pads that only by the sprite half-size, so `emissionBounds` misses half the emission volume. Before any particle exists — a freshly built emitter, or one whose particles have all just died — `bvh_leaf.bounds` is `emissionBounds` alone, so the emitter is dropped from `renderLayer.visibleSet`, `ParticularEngine.updateEmitter` flags it `Sleeping`, and it stops emitting while up to 2 world units of its volume are still on screen. The emitter pops in and out at screen edges.
|
|
496
|
+
- **Fix**: branch on `emissionShape` — full `scale` for `Sphere`, `0.5 * scale` for `Box`.
|
|
497
|
+
- **Confidence**: Confirmed
|
|
498
|
+
|
|
499
|
+
### GFXT-57 Raising `emissionRate` releases a burst of negative-age particles
|
|
500
|
+
- **Severity**: MAJOR
|
|
501
|
+
- **File**: `engine/graphics/particles/particular/engine/emitter/ParticleEmitter.js:891`
|
|
502
|
+
- **Defect**: `spawnLayerParticlesContinuous` seeds its accumulator with `-layer.timeSinceLastEmission` and assigns `initialAge = time`, assuming `timeSinceLastEmission <= emissionPeriod`. That invariant is re-established using the period in force at the *end of the previous call*, so any increase in `emissionRate` (a period shrink) leaves a carry-over larger than the new period. With `emissionRate === 0` it is unbounded: `emissionPeriod` is `Infinity`, the loop never runs, and `timeSinceLastEmission` grows by `timeDelta` every frame forever.
|
|
503
|
+
- **Failure scenario**: a torch layer at `emissionRate = 2` (period 0.5) reaches steady state with `timeSinceLastEmission ≈ 0.5`. Gameplay or the editor raises it to 200 — `editor/view/ecs/components/particles/ParticleLayerController.js:110` does exactly `layer.emissionRate = rate`. On the next 60 Hz frame (`timeDelta = 0.0167`, period 0.005) the loop runs from `time = -0.5` to `0.0117`, emitting roughly 102 particles in one frame instead of about 3. About 99 of them get `initialAge` between `-0.495` and `0`, so each is written at `spawnPos + velocity * negativeAge` — up to half a second of travel *behind* the emitter — and `SimulationStepFixedPhysics` will not retire them until their age climbs back through zero, giving each `deathAge + 0.495 s` of life. Holding `emissionRate = 0` for 10 s first (the `graphics/ecs/sprite/SpriteSystemPE.js:23` configuration) makes the carry-over 10 s and the burst about 2000 particles.
|
|
504
|
+
- **Fix**: clamp the carry-over when seeding — `let time = -min2(layer.timeSinceLastEmission, emissionPeriod);` — and skip the layer entirely when `emissionRate <= 0` rather than accumulating against an infinite period.
|
|
505
|
+
- **Confidence**: Confirmed
|
|
506
|
+
|
|
507
|
+
### GFXT-58 `ParticleLayer` binary round-trip drops the simulation step list
|
|
508
|
+
- **Severity**: MINOR
|
|
509
|
+
- **File**: `engine/graphics/particles/particular/engine/emitter/ParticleLayer.js:358`
|
|
510
|
+
- **Defect**: `toBinaryBuffer`/`fromBinaryBuffer` write and read every layer field except `steps`, and unlike `fromJSON` (line 341) the binary reader has no fallback that installs the default `FixedPhysics` step.
|
|
511
|
+
- **Failure scenario**: `srcLayer.toBinaryBuffer(b); b.position = 0; dstLayer.fromBinaryBuffer(b);` leaves `dstLayer.steps.length === 0`. `ParticularEngine.advanceEmitter` then builds an empty `step_mask` for that layer, so `SimulationStepFixedPhysics` never runs: particles never integrate velocity, `PARTICLE_ATTRIBUTE_AGE` never advances, and `particles.remove(i)` is never reached. The layer emits frozen, immortal particles that accumulate in the pool for the emitter's lifetime. The existing round-trip spec (`ParticleLayer.spec.js:48`) asserts every other field individually and never looks at `steps`, and `hash()`/`equals()` also ignore `steps`, so neither a field test nor a comparison test catches it.
|
|
512
|
+
- **Fix**: write and read `steps` in this pair the way `ParticleEmitterSerializationAdapter` does (count, then per-step type and parameters), falling back to a single `FixedPhysics` step when the count is zero.
|
|
513
|
+
- **Confidence**: Confirmed
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
## geometry — MikkTSpace tangents and mesh optimization
|
|
518
|
+
|
|
519
|
+
### GFXT-59 MikkT angle weighting clamps the corner cosine to `[0,1]`, flattening every obtuse angle to 90°
|
|
520
|
+
- **Severity**: MAJOR
|
|
521
|
+
- **File**: `engine/graphics/geometry/MikkT/EvalTspace.js:104`
|
|
522
|
+
- **Defect**: `const fCos = clamp01(v3_dot(...));` — the reference `mikktspace.c` clamps to `[-1, 1]` (`fCos = fCos > 1 ? 1 : (fCos < -1 ? -1 : fCos)`). Clamping the low end at 0 means `Math.acos` can never exceed 90°, so every obtuse corner is under-weighted in the per-vertex tangent average.
|
|
523
|
+
- **Failure scenario**: a vertex V with three incident triangles whose corner angles at V are 170°, 170° and 20° (a flat fan with neighbours at 0°, 170° and 340°), each with a different affine UV map. Correct weights are 170:170:20; the code produces 90:90:20. Measured against a reference re-implementation, the resulting tangent is rotated by **0.95°** and `fMagS` is off by **2.8%**. A milder 150/105/105 fan gives 0.76° / 0.8%. The error grows without bound as the per-triangle tangents diverge, and it applies to every smoothed vertex with an obtuse incident corner — fan and cap triangulations, decimated meshes, terrain — on the default 180° threshold path.
|
|
524
|
+
- **Fix**: use a `[-1, 1]` clamp, e.g. `clamp(v3_dot(...), -1, 1)`, and drop the `clamp01` import.
|
|
525
|
+
- **Confidence**: Confirmed (numerically measured against a reference re-implementation)
|
|
526
|
+
|
|
527
|
+
### GFXT-60 `merge_by_material` skips an element after every non-merging splice
|
|
528
|
+
- **Severity**: MAJOR
|
|
529
|
+
- **File**: `engine/graphics/geometry/optimization/merge/merge_geometry_hierarchy.js:189`
|
|
530
|
+
- **Defect**: The loop splices the processed entry out of `undecided` while iterating with an incrementing `i`, but only compensates with `i--` when a merge actually happened (`set_indices.length > 1`). On the singleton path the array shrinks under the cursor, so the element that slides into slot `i` is skipped for the rest of the pass and is later re-scanned alone — it never gets compared against its remaining partners.
|
|
531
|
+
- **Failure scenario**: a `Group` with three meshes in order `[A(material m1), B(material m2), C(material m2)]` where B and C are structurally mergeable. Expected output is 2 meshes (A, and B+C); the actual output is **3** — B and C are emitted separately. Reordering the same three children to `[B, C, A]` yields the correct 2. Verified by executing the function.
|
|
532
|
+
- **Fix**: always `i--` after the splice loop (the element removed at index `i` is replaced by its successor), or restructure to repeatedly take `undecided[0]`.
|
|
533
|
+
- **Confidence**: Confirmed (executed)
|
|
534
|
+
|
|
535
|
+
### GFXT-61 `merge_geometry_hierarchy` calls `getIndex().count` on possibly non-indexed geometry
|
|
536
|
+
- **Severity**: MAJOR
|
|
537
|
+
- **File**: `engine/graphics/geometry/optimization/merge/merge_geometry_hierarchy.js:168`
|
|
538
|
+
- **Defect**: The instancing-size heuristic does `const tri_count = (geo_0.getIndex().count) / 3;` without checking for a non-indexed geometry; `BufferGeometry.getIndex()` returns `null` when `index` is unset.
|
|
539
|
+
- **Failure scenario**: a `Group` with two `Mesh` nodes referencing the same non-indexed `BufferGeometry` — or two structurally identical non-indexed geometries, which `dedupe_geometry` collapses into one object earlier in the same function. `merge_geometry_hierarchy` throws `TypeError: Cannot read properties of null (reading 'count')`. Verified by executing the function.
|
|
540
|
+
- **Fix**: `const tri_count = (geo_0.index !== null ? geo_0.index.count : geo_0.attributes.position.count) / 3;`
|
|
541
|
+
- **Confidence**: Confirmed (executed)
|
|
542
|
+
|
|
543
|
+
### GFXT-62 `merge_contexts` wraps a `null` merge result in a `Mesh`
|
|
544
|
+
- **Severity**: MAJOR
|
|
545
|
+
- **File**: `engine/graphics/geometry/optimization/merge/merge_geometry_hierarchy.js:132`
|
|
546
|
+
- **Defect**: The result of `mergeBufferGeometries` is assigned unconditionally. That function `console.error`s and returns `null` for several mismatches `equal_attribute_structure` does not screen — indexed vs non-indexed, `morphAttributes` key mismatch, `morphTargetsRelative` mismatch, interleaved attributes — and the null propagates into `new Mesh(null, material)`.
|
|
547
|
+
- **Failure scenario**: a `Group` with two meshes sharing one material, both carrying only a Float32 `position` attribute of itemSize 3, one indexed and one not. `equal_attribute_structure` returns true (it compares only names, itemSize, normalized and array constructor), so the pair is merged; `mergeBufferGeometries` logs its "make sure index attribute exists among all geometries" error and returns `null`; `context_to_mesh` then throws `TypeError: Cannot read properties of null (reading 'isBufferGeometry')` inside the `Mesh` constructor. Verified by executing the function.
|
|
548
|
+
- **Fix**: extend `equal_attribute_structure` to require `(a.index !== null) === (b.index !== null)`, matching `morphAttributes` keys and equal `morphTargetsRelative`; and in `merge_contexts`, fall back to returning the input contexts unmerged when `merged === null` rather than producing a null-geometry context.
|
|
549
|
+
- **Confidence**: Confirmed (executed)
|
|
550
|
+
|
|
551
|
+
### GFXT-63 Merging bakes a mirrored transform without reversing winding
|
|
552
|
+
- **Severity**: MAJOR
|
|
553
|
+
- **File**: `engine/graphics/geometry/optimization/merge/merge_geometry_hierarchy.js:127`
|
|
554
|
+
- **Defect**: `merge_contexts` bakes each child's world matrix into vertex positions via `applyMatrix4` but never reverses triangle winding for a negative-determinant (mirrored) transform. Unmerged, three.js compensates at draw time — `WebGLRenderer` sets `frontFaceCW` from `object.matrixWorld.determinant() < 0` — but the merged mesh has an identity, positive-determinant matrix, so that compensation no longer applies.
|
|
555
|
+
- **Failure scenario**: a `Group` with two meshes sharing one geometry and material, the second with `scale.set(-1, 1, 1)`. After the merge the single output geometry's two triangles have measured signed areas `[+1, -1]`, i.e. the mirrored triangle is now clockwise. With three.js' default `side: FrontSide` the mirrored half is back-face culled and disappears; before the merge it rendered correctly.
|
|
556
|
+
- **Fix**: compute the determinant of `ctx.matrix` in the `contexts.map` callback and, when negative, reverse each triangle's winding on the clone after `applyMatrix4` (swap index elements `[i*3+1]` and `[i*3+2]`, or the corresponding vertex records when non-indexed).
|
|
557
|
+
- **Confidence**: Confirmed (measured signed areas)
|
|
558
|
+
|
|
559
|
+
### GFXT-64 `geometry_optimize_uv_tension` produces all-NaN UVs from a single collapsed edge
|
|
560
|
+
- **Severity**: MAJOR
|
|
561
|
+
- **File**: `engine/graphics/geometry/optimization/geometry_optimize_uv_tension.js:67`
|
|
562
|
+
- **Defect**: The neighbour weight is `1 / edge.lengthSqr` with no zero-length handling. A single pair of coincident positions joined by an edge yields `Infinity` weights, and `Infinity / Infinity` gives `NaN` UVs, which then propagate across the whole mesh over the remaining smoothing passes.
|
|
563
|
+
- **Failure scenario**: a closed octahedron — so there are no boundary edges and nothing is skipped by the `query_edge_is_boundary` guard — with vertex 0 moved onto pole vertex 4, collapsing edge (0,4). That is one degenerate triangle, a routine artifact of imported or stitched meshes. With `steps = 3`, **all 12 UV components come back `NaN`** (measured), destroying the texture coordinates of the entire mesh rather than just the degenerate corner.
|
|
564
|
+
- **Fix**: skip any neighbour whose `edge.lengthSqr` is zero (contributing to neither `sum_*` nor `weight_sum`), and skip the vertex entirely when `weight_sum` ends up zero.
|
|
565
|
+
- **Confidence**: Confirmed (executed: 12 of 12 UV values NaN)
|
|
566
|
+
|
|
567
|
+
### GFXT-65 MikkT edge table is a `Float32Array` holding packed integer indices
|
|
568
|
+
- **Severity**: MINOR
|
|
569
|
+
- **File**: `engine/graphics/geometry/MikkT/InitTriInfo.js:180` (consumed by `BuildNeighborsFast.js:25-26,73-74`)
|
|
570
|
+
- **Defect**: `const pEdges = new Float32Array(iNrTrianglesIn * 3 * 3);` but channels 0 and 1 store `MakeIndex`-encoded integers (`(face << 2) | corner`), not floats. Float32 represents integers exactly only up to 2^24. The reference C uses `int` throughout.
|
|
571
|
+
- **Failure scenario**: a mesh above 4,194,304 triangles — the point at which `(f << 2) | i` exceeds 2^24, e.g. a 5M-triangle scan. `MakeIndex(4500000, 3) = 18000003` is stored as `18000004`, colliding with `MakeIndex(4500001, 0)`. `GetEdge`'s `index_0 === i0_in` tests then match the wrong corner, `FaceNeighbors` links unrelated triangles, and tangent groups merge across disconnected surface regions — visibly wrong normal mapping on the affected faces.
|
|
572
|
+
- **Fix**: allocate `pEdges` as an `Int32Array`, and likewise the `temp_edge_A`/`temp_edge_B` scratch in `BuildNeighborsFast.js:73-74`.
|
|
573
|
+
- **Confidence**: Likely (arithmetic verified; not reproduced at that mesh size)
|
|
574
|
+
|
|
575
|
+
### GFXT-66 `m_getTexCoord` copies 3 components out of a 2-component UV buffer
|
|
576
|
+
- **Severity**: MINOR
|
|
577
|
+
- **File**: `engine/graphics/geometry/MikkT/m_getTexCoord.js:15`
|
|
578
|
+
- **Defect**: `array_copy(pContext.geometry_buffer_vertex_uv, vertex_index * 2, fvTexcOut, 0, 3)` reads three elements from a buffer with a stride of two, so the third output component is the next vertex's U — or reads past the end for the last vertex.
|
|
579
|
+
- **Failure scenario**: the in-module caller `GetTexCoord` immediately overwrites `res[2] = 1`, so the MikkT pipeline itself is unaffected. But `m_getTexCoord` is an exported public function: calling it directly fills `out[2]` with `uv[vertex_index * 2 + 2]` — the neighbouring vertex's U coordinate — and with `NaN` when `vertex_index` is the last vertex.
|
|
580
|
+
- **Fix**: change the copy length from `3` to `2`.
|
|
581
|
+
- **Confidence**: Confirmed
|
|
582
|
+
|
|
583
|
+
### GFXT-67 `merge_geometry_hierarchy` reads stale local matrices
|
|
584
|
+
- **Severity**: MINOR
|
|
585
|
+
- **File**: `engine/graphics/geometry/optimization/merge/merge_geometry_hierarchy.js:60`
|
|
586
|
+
- **Defect**: `compute_transform_matrix` reads `el.matrix` directly and `merge_geometry_hierarchy` never calls `updateMatrixWorld`. Under three.js' default `matrixAutoUpdate`, `object.matrix` is synced from `position`/`quaternion`/`scale` only during a render pass, so a hierarchy assembled in code still has identity local matrices at merge time.
|
|
587
|
+
- **Failure scenario**: `const m = new Mesh(geo, mat); m.position.set(100, 0, 0); group.add(m); merge_geometry_hierarchy(group)` puts the output mesh at `[0, 0, 0]` — the 100-unit offset is silently discarded (measured). Loader-produced scenes happen to work because `GLTFLoader` writes `matrix` itself.
|
|
588
|
+
- **Fix**: call `input.updateMatrixWorld(true)` at the top of `merge_geometry_hierarchy`, before the `traverse`.
|
|
589
|
+
- **Confidence**: Confirmed (executed)
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
## geometry/ribbon, geometry/instancing, texture/reflection, texture/cubemap
|
|
594
|
+
|
|
595
|
+
### GFXT-68 `Ribbon.positionHead` places the two tip vertices on opposite sides in its two branches
|
|
596
|
+
- **Severity**: CRITICAL
|
|
597
|
+
- **File**: `engine/graphics/geometry/ribbon/Ribbon.js:143`
|
|
598
|
+
- **Defect**: The degenerate branch derives `C` from `A` and `D` from `B`, preserving each vertex's side of the ribbon. The normal branch derives the width axis as `vRelativePosition × normal`, which for the ribbon's own geometry normal `(0,0,1)` points toward `B`'s side, so `C` and `D` come out swapped and the head quad is twisted into a bowtie. The two branches contradict each other regardless of the caller's sign convention for `normal`.
|
|
599
|
+
- **Failure scenario**: `new Ribbon(2, 1)` gives head `quads[1]` with `A = (0, 0.5, 0)`, `B = (0, -0.5, 0)`, and `PlaneBufferGeometry` normals `(0,0,1)`. `positionHead(new Vector3(2,0,0), new Vector3(0,0,1), 1)` gives `vCross = (2,0,0) × (0,0,1) = (0,-2,0)` → `C = (2,-0.5,0)` (B's side) and `D = (2,0.5,0)` (A's side). `positionHead(new Vector3(0,0,0), new Vector3(0,0,1), 1)` takes the degenerate path and gives `C = (0,0.5,0)` (A's side), `D = (0,-0.5,0)` (B's side) — the same object and the same `normal`, opposite assignment. On the first (common) path the `A→C` and `B→D` edges cross, so triangle `(A,B,C)` stays CCW while `(B,D,C)` flips to CW: with a default `FrontSide` material half the head segment vanishes and the ribbon visibly pinches and flips at the tip every frame.
|
|
600
|
+
- **Fix**: negate the cross so it agrees with the degenerate branch (which is unambiguously correct, since it reuses the existing A/B directions): `vCross.copy(normal).cross(vRelativePosition);`, keeping `vC = +vCross`, `vD = -vCross`.
|
|
601
|
+
- **Confidence**: Confirmed
|
|
602
|
+
|
|
603
|
+
### GFXT-69 `.exr` environment maps never load — `EXRLoader` cannot produce `UnsignedByteType`
|
|
604
|
+
- **Severity**: CRITICAL
|
|
605
|
+
- **File**: `engine/graphics/texture/cubemap/load_environment_map.js:57`
|
|
606
|
+
- **Defect**: `new EXRLoader().setDataType(UnsignedByteType)` requests an output type three r136's `EXRLoader` does not implement. Its `EXRDecoder` starts with `getter: null, inputSize: null`, both `switch (outputType)` blocks that assign them have only `case FloatType` / `case HalfFloatType`, and the buffer-allocation switch falls through to `default: console.error('THREE.EXRLoader: unsupported type: ', outputType); break;` without returning (verified at `node_modules/three/examples/jsm/loaders/EXRLoader.js:2128,2133,2145,2150,2175,2184,2193`).
|
|
607
|
+
- **Failure scenario**: `await load_environment_map({ path: 'data/textures/env/sky.exr', renderer })` — `setupDecoder` leaves `getter = null` and `byteArray = undefined`, then `parse` reaches `EXRDecoder.byteArray[outIndex] = EXRDecoder.getter(viewer, tmpOffset)` and throws `TypeError: EXRDecoder.getter is not a function`. `FileLoader` routes that to `onError`, so the promise rejects and no environment map is installed — for every `.exr` input, on every call.
|
|
608
|
+
- **Fix**: `.setDataType(HalfFloatType)` (or `FloatType`), imported from `three` in place of `UnsignedByteType`.
|
|
609
|
+
- **Confidence**: Confirmed
|
|
610
|
+
|
|
611
|
+
### GFXT-70 `Ribbon.traverseEdges` seeds from `quads[0]` instead of the tail
|
|
612
|
+
- **Severity**: MAJOR
|
|
613
|
+
- **File**: `engine/graphics/geometry/ribbon/Ribbon.js:174`
|
|
614
|
+
- **Defect**: The traversal seeds from `quads[0]` — the *array* slot — instead of `this.__tail`. `rotate()` deliberately does not reorder `this.quads`; it only relinks `next`/`previous` and moves `__head`/`__tail`. After any rotation the seed edge is an interior edge, which the loop then visits a second time, while the real tail edge is never visited.
|
|
615
|
+
- **Failure scenario**: `const r = createRibbon(3); rotateRibbon(r);` leaves `__tail = quads[1]`, `__head = quads[0]`, `quads[0].previous = quads[2]`. The initialisation `createRibbon` itself performs — `r.traverseEdges((a,b) => { off[a] = 1; off[b] = -1; })` — then visits vertex pairs `(3,7)`, `(0,4)`, `(3,7)`, `(2,6)`. Vertices 1 and 5, the true tail edge, are never touched, so their `off` attribute stays 0 and the shader collapses the ribbon to zero width at the tail segment, while `(3,7)` is written twice.
|
|
616
|
+
- **Fix**: `let q = this.__tail;`
|
|
617
|
+
- **Confidence**: Confirmed
|
|
618
|
+
|
|
619
|
+
### GFXT-71 `Ribbon.traverseEdges` emits index 0 twice, never the last index, and runs the ramp backwards
|
|
620
|
+
- **Severity**: MAJOR
|
|
621
|
+
- **File**: `engine/graphics/geometry/ribbon/Ribbon.js:181`
|
|
622
|
+
- **Defect**: The seed callback passes index `0`, and the loop then starts its own counter at `0` as well, so index 0 is delivered twice and the final index is never delivered. `maxIndex` is passed as `numQuads + 1` while the loop only reaches `numQuads - 1`. The loop also walks head → tail with `i` ascending while the seed call — the tail edge — is also given `0`, so the parameter runs backwards relative to edge order.
|
|
623
|
+
- **Failure scenario**: `new Ribbon(3, 1).traverseLerpEdges(0, 1, cb)` computes `value = 0 + (index / maxIndex) * 1` and yields `0, 0, 0.25, 0.5` for the four edges. The intended `0 → 1` ramp never reaches 1, is clipped to `(N-1)/(N+1)`, and is inverted — the head edge receives 0 while the edge nearest the tail receives the largest value. For `N = 64` the head gets 0 and the maximum is 0.969, so a trail fade runs the wrong way and never reaches full opacity.
|
|
624
|
+
- **Fix**: use a single monotonic counter over `numQuads + 1` edges in tail → head order with `maxIndex = numQuads`, so `index / maxIndex` spans `[0, 1]`.
|
|
625
|
+
- **Confidence**: Confirmed
|
|
626
|
+
|
|
627
|
+
### GFXT-72 `Ribbon.moveToPoint` does not flag the position attribute for re-upload
|
|
628
|
+
- **Severity**: MAJOR
|
|
629
|
+
- **File**: `engine/graphics/geometry/ribbon/Ribbon.js:97`
|
|
630
|
+
- **Defect**: The method writes the raw position array but never sets `position.needsUpdate = true`. Every other position write in the module (`Quad.setAttributePosition`) does set it, so this is an omission rather than a module convention.
|
|
631
|
+
- **Failure scenario**: `const r = createRibbon(16); scene.add(new Mesh(r.geometry, mat)); renderer.render(...)` uploads the positions and caches `attribute.version` in `WebGLAttributes`. Respawning the trail at a new location with `r.moveToPoint(new Vector3(50, 0, 0)); renderer.render(...)` then hits `data.version === attribute.version` in `WebGLAttributes.update`, which skips the upload — the frame still draws the full 16-segment trail at the old location instead of a collapsed ribbon at `(50,0,0)`.
|
|
632
|
+
- **Fix**: add `position.needsUpdate = true;` after the loop.
|
|
633
|
+
- **Confidence**: Confirmed
|
|
634
|
+
|
|
635
|
+
### GFXT-73 First-time colour attribute creation leaves existing instances fully transparent
|
|
636
|
+
- **Severity**: MINOR
|
|
637
|
+
- **File**: `engine/graphics/geometry/instancing/InstancedMeshGroup.js:575`
|
|
638
|
+
- **Defect**: When the colour attribute is created for the first time on a group that already has live instances, only `[count, capacity)` is filled with `255` — slots `[0, count)` stay at the `Uint8Array` default of `0`, because there is no old array to copy from. (Distinct from the known stale-colour-on-regrow issue: here the colours were never written at all.)
|
|
639
|
+
- **Failure scenario**: build a group with `use_color = false`, add 50 instances with transforms, then set `use_color = true` and `setCapacity(128)` to trigger the first build with colours. Slots 0..49 hold `(0,0,0,0)`; `rewriteMaterial` defines `USE_COLOR_ALPHA` so `diffuseColor *= vColor` with alpha 0 — all 50 live instances render fully transparent while instances added afterwards render correctly.
|
|
640
|
+
- **Fix**: fill the whole array when there was no previous one — `newColorArray.fill(255, this.__attributeColor === null ? 0 : this.count * 4)`.
|
|
641
|
+
- **Confidence**: Confirmed
|
|
642
|
+
|
|
643
|
+
### GFXT-74 Reflection convolution takes its channel count from the input, not the output
|
|
644
|
+
- **Severity**: MINOR
|
|
645
|
+
- **File**: `engine/graphics/texture/reflection/convolve_equirectangular_reflection.js:56` (caller at `:118`)
|
|
646
|
+
- **Defect**: `convolve_equirectangular_reflection_direction` decides how many channels to write into `result` from `input.itemSize`, but the caller sizes `result` from `output.itemSize` (`const texel = new Float32Array(output.itemSize);`). When they differ, the trailing channels of every output texel are left untouched.
|
|
647
|
+
- **Failure scenario**: `convolve_equirectangular_reflection_level(rgbSky /* itemSize 3 */, rgbaMip /* itemSize 4 */, 0.3, 32)` allocates a fresh `Float32Array(4)` per texel whose element 3 is never assigned, so `output.write` stores alpha 0 for every texel and the prefiltered mip comes out fully transparent.
|
|
648
|
+
- **Fix**: take the channel count from the destination — pass an explicit `channel_count`, or use `result.length - result_offset` — and read `_radiance[3]` only when the input actually has a fourth channel.
|
|
649
|
+
- **Confidence**: Confirmed
|
|
650
|
+
|
|
651
|
+
### GFXT-75 Cubemap face-array branch compares the file extension case-sensitively
|
|
652
|
+
- **Severity**: MINOR
|
|
653
|
+
- **File**: `engine/graphics/texture/cubemap/load_environment_map.js:104`
|
|
654
|
+
- **Defect**: The six-face array branch does `const ext = computeFileExtension(path[0]); if (ext === 'hdr')`, while the single-path branch at line 116 lowercases first. An uppercase face set silently takes the LDR `CubeTextureLoader` path.
|
|
655
|
+
- **Failure scenario**: `load_and_set_cubemap_v0(graphics, 'data/textures/cubemaps/sky/', '.HDR')` has `buildCubeURLs` yield `.../posx.HDR`; `ext === 'HDR'` fails the check, so `CubeTextureLoader` (`ImageLoader`) is used, the browser cannot decode a Radiance file as an image, and the promise rejects instead of loading the HDR cubemap.
|
|
656
|
+
- **Fix**: `const ext = computeFileExtension(path[0]).toLowerCase();`
|
|
657
|
+
- **Confidence**: Confirmed
|
|
658
|
+
|
|
659
|
+
### GFXT-76 `geometry_copy` drops `drawRange`
|
|
660
|
+
- **Severity**: MINOR
|
|
661
|
+
- **File**: `engine/graphics/geometry/instancing/geometry_copy.js:6`
|
|
662
|
+
- **Defect**: The function copies `index` and `attributes` but not `drawRange`, so the target reverts to the `{start: 0, count: Infinity}` default and renders the source's entire index buffer. (`groups` and `morphAttributes` are dropped too, but with a single non-array material three ignores `groups`, so only `drawRange` produces wrong output.)
|
|
663
|
+
- **Failure scenario**: a pooled geometry that keeps a fixed-size buffer and publishes its live element count via `setDrawRange` — the pattern at `particles/particular/engine/emitter/ParticlePool.js:158`, `geometry.setDrawRange(0, occupancy.size())` — passed to `InstancedMeshGroup.setGeometry` or `geometry_copy` with 120 live of 10000 allocated elements renders all 10000, drawing 9880 stale/uninitialised quads.
|
|
664
|
+
- **Fix**: also copy `target.setDrawRange(source.drawRange.start, source.drawRange.count)`, plus `morphAttributes` and `morphTargetsRelative`.
|
|
665
|
+
- **Confidence**: Confirmed
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## Status of the pre-existing "already known" list
|
|
670
|
+
|
|
671
|
+
| Item | Status |
|
|
672
|
+
| --- | --- |
|
|
673
|
+
| `sampler2d_scale_down_lanczos` `rcp_ratio_y = 2/ratio_x` | FIXED — now `2 / ratio_y` (`:93`) |
|
|
674
|
+
| `sampler2d_scale_down_linear` non-integer-ratio mis-index | OPEN — `sampleOffsetX = x * sW` is still used as a texel index, and `for (i = sW; i > 0; i--)` still over-counts (`:50-58`) |
|
|
675
|
+
| `sampler2d_copy_with_margins` width used for a vertical bound | FIXED — all vertical bounds now use `destination.height`. A different, still-open bug in the same file is filed as GFXT-30 |
|
|
676
|
+
| `ParticleGroup.deleteIndices` bare `.sort()` | FIXED — now `indices.sort((a, b) => a - b)`. A different, still-open bug in the same function is filed as GFXT-51 |
|
|
677
|
+
| `ParticlePool` `updateRange.count` missing `* itemSize` | FIXED — both sites multiply (`:177`, `:507`) |
|
|
678
|
+
| `SimulationStepCurlNoiseVelocity` shared `this.time` | OPEN — still `this.time += this.timeDelta` on the shared step object |
|
|
679
|
+
| `ManagedTextureAtlas` serving atlas-evicted patches from a TTL-∞ cache | OPEN — `LoadingCache` is still constructed with no TTL |
|
|
680
|
+
| `CachingTextureAtlas.__evict_all` leaving index 0 | FIXED — loop runs `i >= 0` |
|
|
681
|
+
| `ParticleEmitter` hash/equals flag disagreement | FIXED — both now mask with `SERIALIZABLE_FLAGS`. A different issue with that mask is filed as GFXT-55 |
|
|
682
|
+
| `ShaderManager` graveyard leak | FIXED — `cleanup()` evicts from both maps and releases the material reference |
|
|
683
|
+
| `equal_attribute_structure` returning truthy `-1` | FIXED — now `indexOf(...) === -1` guarded. Other still-open bugs in that function are filed as GFXT-62 |
|
|
684
|
+
| `InstancedMeshGroup.build` stale per-instance colours on shrink+regrow | FIXED — `newColorArray.fill(255, this.count * 4)`. A different first-build case is filed as GFXT-73 |
|
|
685
|
+
| MikkT `BuildNeighborsFast` edge-neighbour pairing | OPEN — `i1_B` and `edgenum_B` both read `temp_edge_B[2]` (`:117-118`) |
|
|
686
|
+
| MikkT `GenerateSharedVerticesIndexList` radix precedence | OPEN — `pass * 257 + (_comp_0[i] >> shift) & 0xff` at `:79`, versus the correctly parenthesised `:59` |
|
|
687
|
+
| `bvh32_geometry_nearest` duplicated denormalize block | FIXED — one block remains |
|
|
688
|
+
| `ensureGeometryBoundingSphere` returning stale null | FIXED — assigns to `geometry.boundingSphere` and returns it |
|
|
689
|
+
| `AtlasLookupTexture` writing `size.y` into the width channel | FIXED — writes `position.x, position.y, size.x, size.y` |
|
|
690
|
+
|