From 77f09d0d44e8db60e08f0ba05748482646e087eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 28 Jun 2026 12:24:17 +0200 Subject: [PATCH 01/12] Add all-hit TLAS traversal API --- src/Raycore.jl | 2 +- src/instanced-bvh.jl | 173 +++++++++++++++++++++++++++++++++++++ test/test_instanced_bvh.jl | 46 ++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) diff --git a/src/Raycore.jl b/src/Raycore.jl index 77fd57f..5630232 100644 --- a/src/Raycore.jl +++ b/src/Raycore.jl @@ -106,7 +106,7 @@ export BVHNode4, BLAS4, TLAS4, build_blas4, closest_hit4, any_hit4 # Ray intersection functions export AbstractAccel, AbstractAdaptedAccel -export closest_hit, any_hit, world_bound, trace_rays +export closest_hit, any_hit, all_hits!, world_bound, trace_rays export n_instances, n_geometries, wait_for_gpu! # RT transport types (used by Lava.HWTLAS and consumers) diff --git a/src/instanced-bvh.jl b/src/instanced-bvh.jl index d82ae08..5873078 100644 --- a/src/instanced-bvh.jl +++ b/src/instanced-bvh.jl @@ -2023,6 +2023,179 @@ Algorithm: end end +@inline function _insert_sorted_unique_hit!( + metadata_out, + distances_out, + out_base::Int, + count::Int32, + max_hits::Int, + metadata::UInt32, + distance::Float32, + duplicate_epsilon::Float32, +) + count_int = Int(count) + @inbounds for i in 1:count_int + out_idx = out_base + i + if metadata_out[out_idx] == metadata && abs(distances_out[out_idx] - distance) <= duplicate_epsilon + return count, false + end + end + + if count_int < max_hits + insert_pos = count_int + 1 + @inbounds while insert_pos > 1 && distances_out[out_base+insert_pos-1] > distance + metadata_out[out_base+insert_pos] = metadata_out[out_base+insert_pos-1] + distances_out[out_base+insert_pos] = distances_out[out_base+insert_pos-1] + insert_pos -= 1 + end + @inbounds begin + metadata_out[out_base+insert_pos] = metadata + distances_out[out_base+insert_pos] = distance + end + return count + Int32(1), false + end + + if max_hits <= 0 + return count, true + end + + @inbounds if distance >= distances_out[out_base+max_hits] + return count, true + end + + insert_pos = max_hits + @inbounds while insert_pos > 1 && distances_out[out_base+insert_pos-1] > distance + metadata_out[out_base+insert_pos] = metadata_out[out_base+insert_pos-1] + distances_out[out_base+insert_pos] = distances_out[out_base+insert_pos-1] + insert_pos -= 1 + end + @inbounds begin + metadata_out[out_base+insert_pos] = metadata + distances_out[out_base+insert_pos] = distance + end + return count, true +end + +""" + all_hits!(metadata_out, distances_out, tlas::StaticTLAS, ray, out_base, max_hits, duplicate_epsilon) + +Traverse a `StaticTLAS` once and write sorted hit metadata and hit distances +into caller-provided buffers. + +`out_base` is a zero-based offset into the output buffers, so hits are written +to `out_base + 1:out_base + count`. The function returns `(count, overflow)`. +When more than `max_hits` unique hits are found, the closest `max_hits` hits are +retained and `overflow` is set. + +Hits with the same triangle metadata and a distance difference no larger than +`duplicate_epsilon` are collapsed. This keeps coplanar duplicate triangles from +using extra stack slots while remaining GPU-kernel friendly. +""" +@inline function all_hits!( + metadata_out, + distances_out, + tlas::StaticTLAS, + ray::R, + out_base::Int, + max_hits::Int, + duplicate_epsilon::Float32, +) where {R <: AbstractRay} + ray = check_direction(ray) + ray_o::Point3f = ray.o + ray_d::Vec3f = ray.d + ray_mint::Float32 = ray.t_min + ray_maxt::Float32 = ray.t_max + ray_inv_d::Vec3f = safe_invdir(ray_d) + + stack = MVector{32, UInt32}(undef) + stack_ptr::Int32 = Int32(1) + @inbounds stack[stack_ptr] = INVALID_NODE + + current_instance::Int32 = Int32(-1) + node_index::UInt32 = UInt32(1) + current_blas_offset::UInt32 = UInt32(0) + current_prim_offset::UInt32 = UInt32(0) + count::Int32 = Int32(0) + overflow::Bool = false + + tlas_nodes = tlas.nodes + tlas_instances = tlas.instances + tlas_blas_nodes = tlas.all_blas_nodes + tlas_blas_prims = tlas.all_blas_prims + tlas_blas_descs = tlas.blas_descriptors + + @inbounds while node_index != INVALID_NODE + node::BVHNode2 = if current_instance < Int32(0) + tlas_nodes[node_index] + else + tlas_blas_nodes[current_blas_offset + node_index] + end + + is_leaf::Bool = (node.child0 == INVALID_NODE) + + if !is_leaf + near_child, far_child = intersect_internal_node(node, ray_inv_d, ray_o, ray_mint, ray_maxt) + + if far_child != INVALID_NODE + stack_ptr += Int32(1) + stack[stack_ptr] = far_child + end + + if near_child != INVALID_NODE + node_index = near_child + continue + end + elseif current_instance < Int32(0) + current_instance = Int32(node.child1) + + stack_ptr += Int32(1) + stack[stack_ptr] = TOP_LEVEL_SENTINEL + + node_index = UInt32(1) + inst = tlas_instances[current_instance + Int32(1)] + desc = tlas_blas_descs[inst.blas_index] + current_blas_offset = desc.nodes_offset + current_prim_offset = desc.primitives_offset + ray_o = transform_point(inst.inv_transform, ray.o) + ray_d = transform_direction(inst.inv_transform, ray.d) + ray_inv_d = safe_invdir(ray_d) + continue + else + hit, distance, _u, _v = intersect_leaf_node(node, ray_d, ray_o, ray_mint, ray_maxt) + if hit + tri = tlas_blas_prims[current_prim_offset + node.child1] + new_count, hit_overflow = _insert_sorted_unique_hit!( + metadata_out, + distances_out, + out_base, + count, + max_hits, + tri.metadata, + distance, + duplicate_epsilon, + ) + count = new_count + overflow |= hit_overflow + end + end + + node_index = stack[stack_ptr] + stack_ptr -= Int32(1) + + if node_index == TOP_LEVEL_SENTINEL + node_index = stack[stack_ptr] + stack_ptr -= Int32(1) + current_instance = Int32(-1) + + ray_o = ray.o + ray_d = ray.d + ray_inv_d = safe_invdir(ray_d) + end + end + + return count, overflow +end + """ any_hit(tlas::TLAS, ray::AbstractRay) -> (hit, primitive, distance, barycentric, instance_idx) diff --git a/test/test_instanced_bvh.jl b/test/test_instanced_bvh.jl index 5e550ca..3444a3a 100644 --- a/test/test_instanced_bvh.jl +++ b/test/test_instanced_bvh.jl @@ -377,6 +377,52 @@ end @test inst_id == UInt32(1) # First instance end +@testset "TLAS all_hits! - Sorted Multiple Hits" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(7) + ) + + blas = build_blas([tri]) + identity = Mat4f(I) + translate_back = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ) + inv_translate_back = Mat4f(inv(translate_back)) + + instances = [ + InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(2), translate_back, inv_translate_back, UInt32(0)) + ] + tlas = build_tlas([blas], instances) + + metadata = fill(UInt32(0), 4) + distances = fill(0.0f0, 4) + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 4, 0.0f0) + + @test count == Int32(2) + @test overflow == false + @test metadata[1:2] == UInt32[7, 7] + @test distances[1] ≈ 1.0f0 + @test distances[2] ≈ 6.0f0 + + limited_metadata = fill(UInt32(0), 1) + limited_distances = fill(0.0f0, 1) + limited_count, limited_overflow = all_hits!(limited_metadata, limited_distances, tlas, ray, 0, 1, 0.0f0) + @test limited_count == Int32(1) + @test limited_overflow == true + @test limited_metadata[1] == UInt32(7) + @test limited_distances[1] ≈ 1.0f0 +end + @testset "TLAS any_hit - Basic" begin # Create a unit triangle v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) From e2751ff186a7d9ac83bd2e1f82330661e4ece420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 28 Jun 2026 13:26:27 +0200 Subject: [PATCH 02/12] Add all_hits duplicate and overflow tests --- test/test_instanced_bvh.jl | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/test/test_instanced_bvh.jl b/test/test_instanced_bvh.jl index 3444a3a..2421ff3 100644 --- a/test/test_instanced_bvh.jl +++ b/test/test_instanced_bvh.jl @@ -423,6 +423,76 @@ end @test limited_distances[1] ≈ 1.0f0 end +@testset "TLAS all_hits! - Duplicate Suppression" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(9) + ) + + blas = build_blas([tri, tri]) + identity = Mat4f(I) + instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] + tlas = build_tlas([blas], instances) + + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + metadata = fill(UInt32(0), 4) + distances = fill(0.0f0, 4) + count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 4, 1.0f-5) + + @test count == Int32(1) + @test overflow == false + @test metadata[1] == UInt32(9) + @test distances[1] ≈ 1.0f0 +end + +@testset "TLAS all_hits! - Overflow Keeps Closest Hits" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(11) + ) + + blas = build_blas([tri]) + identity = Mat4f(I) + translate_back_2 = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -2, 1 + ) + translate_back_5 = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ) + + instances = [ + InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(2), translate_back_2, Mat4f(inv(translate_back_2)), UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(3), translate_back_5, Mat4f(inv(translate_back_5)), UInt32(0)) + ] + tlas = build_tlas([blas], instances) + + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + metadata = fill(UInt32(0), 2) + distances = fill(0.0f0, 2) + count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 2, 0.0f0) + + @test count == Int32(2) + @test overflow == true + @test metadata == UInt32[11, 11] + @test distances[1] ≈ 1.0f0 + @test distances[2] ≈ 3.0f0 +end + @testset "TLAS any_hit - Basic" begin # Create a unit triangle v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) From b68f6c5f0a16113dbae1e94caee9c4cd6a5effc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 28 Jun 2026 17:35:01 +0200 Subject: [PATCH 03/12] Add all_hits KernelAbstractions kernel coverage --- test/test_instanced_bvh.jl | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/test/test_instanced_bvh.jl b/test/test_instanced_bvh.jl index 2421ff3..81c0d43 100644 --- a/test/test_instanced_bvh.jl +++ b/test/test_instanced_bvh.jl @@ -8,6 +8,8 @@ using GeometryBasics using StaticArrays using LinearAlgebra using KernelAbstractions +import KernelAbstractions as KA +using KernelAbstractions: @index # Use qualified names to avoid conflicts with other packages const RTriangle = Raycore.Triangle # Conflicts with GeometryBasics.Triangle @@ -15,6 +17,18 @@ const RBLAS = Raycore.BLAS # Conflicts with LinearAlgebra.BLAS const is_leaf = Raycore.is_leaf const is_interior = Raycore.is_interior +# Kernel: all_hits! writes sorted hit stacks into caller-provided buffers +KA.@kernel function all_hits_kernel!(metadata_out, distances_out, counts_out, overflow_out, tlas, origins, directions, max_hits::Int, duplicate_epsilon::Float32) + i = @index(Global, Linear) + @inbounds begin + ray = Ray(o=origins[i], d=directions[i]) + out_base = (i - 1) * max_hits + count, overflow = all_hits!(metadata_out, distances_out, tlas, ray, out_base, max_hits, duplicate_epsilon) + counts_out[i] = count + overflow_out[i] = overflow + end +end + @testset "Instanced BVH" begin @testset "Morton Code Generation" begin @@ -493,6 +507,69 @@ end @test distances[2] ≈ 3.0f0 end +@testset "TLAS all_hits! - KernelAbstractions Kernel" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(13) + ) + + blas = build_blas([tri]) + identity = Mat4f(I) + translate_back = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ) + tlas = build_tlas( + [blas], + [ + InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(2), translate_back, Mat4f(inv(translate_back)), UInt32(0)), + ], + ) + + backend = KernelAbstractions.CPU() + n = 2 + origins = KA.allocate(backend, Point3f, n) + directions = KA.allocate(backend, Vec3f, n) + KA.copyto!(backend, origins, [Point3f(0.25, 0.25, 1.0), Point3f(5.0, 5.0, 1.0)]) + KA.copyto!(backend, directions, fill(Vec3f(0, 0, -1), n)) + + max_hits = 2 + metadata = KA.allocate(backend, UInt32, n * max_hits) + distances = KA.allocate(backend, Float32, n * max_hits) + counts = KA.allocate(backend, Int32, n) + overflow = KA.allocate(backend, Bool, n) + + kernel = all_hits_kernel!(backend) + kernel(metadata, distances, counts, overflow, tlas, origins, directions, max_hits, 0.0f0; ndrange=n) + KA.synchronize(backend) + + @test Array(counts) == Int32[2, 0] + @test Array(overflow) == Bool[false, false] + distances_cpu = Array(distances) + @test distances_cpu[1] ≈ 1.0f0 + @test distances_cpu[2] ≈ 6.0f0 + @test Array(metadata)[1:2] == UInt32[13, 13] + + limited_metadata = KA.allocate(backend, UInt32, n) + limited_distances = KA.allocate(backend, Float32, n) + limited_counts = KA.allocate(backend, Int32, n) + limited_overflow = KA.allocate(backend, Bool, n) + kernel(limited_metadata, limited_distances, limited_counts, limited_overflow, tlas, origins, directions, 1, 0.0f0; ndrange=n) + KA.synchronize(backend) + + @test Array(limited_counts) == Int32[1, 0] + @test Array(limited_overflow) == Bool[true, false] + @test Array(limited_distances)[1] ≈ 1.0f0 + @test Array(limited_metadata)[1] == UInt32(13) +end + @testset "TLAS any_hit - Basic" begin # Create a unit triangle v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) From 1644a576d26406c626fd7aa0c1ac5d90b539567e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 28 Jun 2026 17:41:25 +0200 Subject: [PATCH 04/12] Exercise all_hits in dynamic backend kernel tests --- test/test_instanced_bvh.jl | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/test/test_instanced_bvh.jl b/test/test_instanced_bvh.jl index 81c0d43..a16fff3 100644 --- a/test/test_instanced_bvh.jl +++ b/test/test_instanced_bvh.jl @@ -1032,6 +1032,58 @@ else @test distances_cpu[2] ≈ 1.0f0 end + @testset "all_hits_kernel! - sorted stacks and overflow" begin + mesh = make_triangle_mesh() + translate_back = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ) + tlas = Raycore.TLAS(cl_backend) + push!(tlas, mesh, [Mat4f(I), translate_back]) + sync!(tlas) + cl_tlas = Adapt.adapt(cl_backend, tlas) + + n = 2 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + KA.copyto!(cl_backend, origins, [ + Point3f(0.25, 0.25, 1.0), + Point3f(5.0, 5.0, 1.0), + ]) + KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) + + max_hits = 2 + metadata = KA.allocate(cl_backend, UInt32, n * max_hits) + distances = KA.allocate(cl_backend, Float32, n * max_hits) + counts = KA.allocate(cl_backend, Int32, n) + overflow = KA.allocate(cl_backend, Bool, n) + + kernel = all_hits_kernel!(cl_backend) + kernel(metadata, distances, counts, overflow, cl_tlas, origins, directions, max_hits, 0.0f0; ndrange=n) + KA.synchronize(cl_backend) + + counts_cpu = Array(counts) + overflow_cpu = Array(overflow) + distances_cpu = Array(distances) + @test counts_cpu == Int32[2, 0] + @test overflow_cpu == Bool[false, false] + @test distances_cpu[1] ≈ 1.0f0 + @test distances_cpu[2] ≈ 6.0f0 + + limited_metadata = KA.allocate(cl_backend, UInt32, n) + limited_distances = KA.allocate(cl_backend, Float32, n) + limited_counts = KA.allocate(cl_backend, Int32, n) + limited_overflow = KA.allocate(cl_backend, Bool, n) + kernel(limited_metadata, limited_distances, limited_counts, limited_overflow, cl_tlas, origins, directions, 1, 0.0f0; ndrange=n) + KA.synchronize(cl_backend) + + @test Array(limited_counts) == Int32[1, 0] + @test Array(limited_overflow) == Bool[true, false] + @test Array(limited_distances)[1] ≈ 1.0f0 + end + @testset "any_hit_kernel! - shadow/occlusion test" begin mesh = make_triangle_mesh() tlas, _ = TLAS([mesh]; backend=cl_backend) From acf8e38c4032dddd5a3b19f092a0dedd7d477580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 28 Jun 2026 18:59:31 +0200 Subject: [PATCH 05/12] Update test_instanced_bvh.jl --- test/test_instanced_bvh.jl | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/test_instanced_bvh.jl b/test/test_instanced_bvh.jl index a16fff3..2c9be54 100644 --- a/test/test_instanced_bvh.jl +++ b/test/test_instanced_bvh.jl @@ -8,8 +8,6 @@ using GeometryBasics using StaticArrays using LinearAlgebra using KernelAbstractions -import KernelAbstractions as KA -using KernelAbstractions: @index # Use qualified names to avoid conflicts with other packages const RTriangle = Raycore.Triangle # Conflicts with GeometryBasics.Triangle @@ -18,7 +16,7 @@ const is_leaf = Raycore.is_leaf const is_interior = Raycore.is_interior # Kernel: all_hits! writes sorted hit stacks into caller-provided buffers -KA.@kernel function all_hits_kernel!(metadata_out, distances_out, counts_out, overflow_out, tlas, origins, directions, max_hits::Int, duplicate_epsilon::Float32) +KernelAbstractions.@kernel function all_hits_kernel!(metadata_out, distances_out, counts_out, overflow_out, tlas, origins, directions, max_hits::Int, duplicate_epsilon::Float32) i = @index(Global, Linear) @inbounds begin ray = Ray(o=origins[i], d=directions[i]) From b2a54d62c2893869c847b5e4540ba4a49ecb16b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 28 Jun 2026 19:08:43 +0200 Subject: [PATCH 06/12] Metadata type should stay open (whatever the user is passing) --- src/instanced-bvh.jl | 4 +- test/test_instanced_bvh.jl | 2504 ++++++++++++++++++------------------ 2 files changed, 1277 insertions(+), 1231 deletions(-) diff --git a/src/instanced-bvh.jl b/src/instanced-bvh.jl index 5873078..698fa94 100644 --- a/src/instanced-bvh.jl +++ b/src/instanced-bvh.jl @@ -2029,10 +2029,10 @@ end out_base::Int, count::Int32, max_hits::Int, - metadata::UInt32, + metadata::TMetadata, distance::Float32, duplicate_epsilon::Float32, -) +) where {TMetadata} count_int = Int(count) @inbounds for i in 1:count_int out_idx = out_base + i diff --git a/test/test_instanced_bvh.jl b/test/test_instanced_bvh.jl index 2c9be54..9a70ae9 100644 --- a/test/test_instanced_bvh.jl +++ b/test/test_instanced_bvh.jl @@ -29,825 +29,865 @@ end @testset "Instanced BVH" begin -@testset "Morton Code Generation" begin - # Test Morton code for known points - p1 = Point3f(0.0, 0.0, 0.0) - p2 = Point3f(1.0, 1.0, 1.0) - p3 = Point3f(0.5, 0.5, 0.5) - - code1 = Raycore.morton_code_30bit(p1) - code2 = Raycore.morton_code_30bit(p2) - code3 = Raycore.morton_code_30bit(p3) - - @test code1 isa UInt32 - @test code2 isa UInt32 - @test code3 isa UInt32 - - # Morton codes should order points along Z-curve - @test code1 < code2 - @test code1 < code3 < code2 -end + @testset "Morton Code Generation" begin + # Test Morton code for known points + p1 = Point3f(0.0, 0.0, 0.0) + p2 = Point3f(1.0, 1.0, 1.0) + p3 = Point3f(0.5, 0.5, 0.5) + + code1 = Raycore.morton_code_30bit(p1) + code2 = Raycore.morton_code_30bit(p2) + code3 = Raycore.morton_code_30bit(p3) + + @test code1 isa UInt32 + @test code2 isa UInt32 + @test code3 isa UInt32 + + # Morton codes should order points along Z-curve + @test code1 < code2 + @test code1 < code3 < code2 + end -@testset "BLAS Construction - Single Triangle" begin - # Create a single triangle - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - nothing - ) - - primitives = [tri] - blas = build_blas(primitives) - - @test blas isa RBLAS - @test length(blas.nodes) == 1 # Single triangle = 1 node (leaf) - @test length(blas.primitives) == 1 - @test is_leaf(blas.nodes[1]) - @test blas.nodes[1].child1 == UInt32(1) # Points to primitive 1 -end + @testset "BLAS Construction - Single Triangle" begin + # Create a single triangle + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + nothing + ) -@testset "BLAS Construction - Multiple Triangles" begin - # Create a simple quad (2 triangles) - v1 = Point3f(0, 0, 0) - v2 = Point3f(1, 0, 0) - v3 = Point3f(1, 1, 0) - v4 = Point3f(0, 1, 0) - - tri1 = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(1, 1)), - nothing - ) - - tri2 = RTriangle( - SVector(v1, v3, v4), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 1), Point2f(0, 1)), - nothing - ) - - primitives = [tri1, tri2] - blas = build_blas(primitives) - - @test blas isa RBLAS - @test length(blas.nodes) == 3 # 2 leaves + 1 interior = 3 nodes - @test length(blas.primitives) == 2 - - # Check root is interior node - @test is_interior(blas.nodes[1]) - - # Check root AABB contains all primitives - root_aabb = blas.root_aabb - @test root_aabb.p_min[1] ≈ 0.0f0 - @test root_aabb.p_min[2] ≈ 0.0f0 - @test root_aabb.p_max[1] ≈ 1.0f0 - @test root_aabb.p_max[2] ≈ 1.0f0 -end + primitives = [tri] + blas = build_blas(primitives) -@testset "BLAS Type Stability" begin - # Test type stability of build_blas - v1 = Point3f(0, 0, 0) - v2 = Point3f(1, 0, 0) - v3 = Point3f(0, 1, 0) - - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - nothing - ) - - primitives = [tri] - - # build_blas should be type-stable - result_type = @inferred build_blas(primitives) - @test result_type isa RBLAS -end + @test blas isa RBLAS + @test length(blas.nodes) == 1 # Single triangle = 1 node (leaf) + @test length(blas.primitives) == 1 + @test is_leaf(blas.nodes[1]) + @test blas.nodes[1].child1 == UInt32(1) # Points to primitive 1 + end -@testset "Transform Utilities" begin - # Test point transformation - identity = Mat4f(I) - p = Point3f(1, 2, 3) - p_transformed = Raycore.transform_point(identity, p) - @test p_transformed ≈ p - - # Test translation - translation = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 5, 10, 15, 1 - ) - p_translated = Raycore.transform_point(translation, p) - @test p_translated ≈ Point3f(6, 12, 18) - - # Test direction transformation (should ignore translation) - v = Vec3f(1, 0, 0) - v_transformed = Raycore.transform_direction(translation, v) - @test v_transformed ≈ v - - # Test type stability - @test (@inferred Raycore.transform_point(identity, p)) isa Point3f - @test (@inferred Raycore.transform_direction(identity, v)) isa Vec3f -end + @testset "BLAS Construction - Multiple Triangles" begin + # Create a simple quad (2 triangles) + v1 = Point3f(0, 0, 0) + v2 = Point3f(1, 0, 0) + v3 = Point3f(1, 1, 0) + v4 = Point3f(0, 1, 0) + + tri1 = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(1, 1)), + nothing + ) -@testset "BVHNode2 Utilities" begin - # Test leaf detection - leaf_node = BVHNode2( - Point3f(0), Point3f(1), - Point3f(0), Point3f(0), - INVALID_NODE, UInt32(5), INVALID_NODE - ) - @test is_leaf(leaf_node) - @test !is_interior(leaf_node) - - # Test interior detection - interior_node = BVHNode2( - Point3f(0), Point3f(1), - Point3f(0), Point3f(1), - UInt32(2), UInt32(3), INVALID_NODE - ) - @test !is_leaf(interior_node) - @test is_interior(interior_node) - - # Test AABB extraction - aabb = Raycore.get_node_aabb(interior_node, true) - @test aabb isa Bounds3 - @test aabb.p_min == Point3f(0, 0, 0) - @test aabb.p_max == Point3f(1, 1, 1) -end + tri2 = RTriangle( + SVector(v1, v3, v4), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 1), Point2f(0, 1)), + nothing + ) -@testset "AABB Utilities" begin - # Test expand_bits - @test Raycore.expand_bits(UInt32(0)) == UInt32(0) - @test Raycore.expand_bits(UInt32(1)) isa UInt32 + primitives = [tri1, tri2] + blas = build_blas(primitives) - # Test clz32 - @test Raycore.clz32(UInt32(0)) == Int32(32) - @test Raycore.clz32(UInt32(1)) == Int32(31) - @test Raycore.clz32(UInt32(0x80000000)) == Int32(0) -end + @test blas isa RBLAS + @test length(blas.nodes) == 3 # 2 leaves + 1 interior = 3 nodes + @test length(blas.primitives) == 2 -@testset "Delta Function (LCP)" begin - # Test longest common prefix calculation - codes = UInt32[0x00000001, 0x00000002, 0x00000004, 0x00000008] + # Check root is interior node + @test is_interior(blas.nodes[1]) - # Adjacent codes with different prefixes - d1 = Raycore.delta(Int32(1), Int32(2), codes, Int32(4)) - d2 = Raycore.delta(Int32(2), Int32(3), codes, Int32(4)) + # Check root AABB contains all primitives + root_aabb = blas.root_aabb + @test root_aabb.p_min[1] ≈ 0.0f0 + @test root_aabb.p_min[2] ≈ 0.0f0 + @test root_aabb.p_max[1] ≈ 1.0f0 + @test root_aabb.p_max[2] ≈ 1.0f0 + end - @test d1 isa Int32 - @test d2 isa Int32 + @testset "BLAS Type Stability" begin + # Test type stability of build_blas + v1 = Point3f(0, 0, 0) + v2 = Point3f(1, 0, 0) + v3 = Point3f(0, 1, 0) + + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + nothing + ) - # Out of bounds should return -1 - d_oob = Raycore.delta(Int32(1), Int32(10), codes, Int32(4)) - @test d_oob == Int32(-1) -end + primitives = [tri] -# ============================================================================== -# TLAS Construction Tests -# ============================================================================== + # build_blas should be type-stable + result_type = @inferred build_blas(primitives) + @test result_type isa RBLAS + end -@testset "TLAS Construction - Single Instance" begin - # Create a single triangle as BLAS - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(1) - ) - - blas = build_blas([tri]) - identity = Mat4f(I) - instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] - - tlas = build_tlas([blas], instances) - - @test tlas isa Raycore.StaticTLAS - @test length(tlas.instances) == 1 - @test length(tlas.blas_descriptors) == 1 - @test length(tlas.nodes) == 1 # Single instance = 1 node (leaf) -end + @testset "Transform Utilities" begin + # Test point transformation + identity = Mat4f(I) + p = Point3f(1, 2, 3) + p_transformed = Raycore.transform_point(identity, p) + @test p_transformed ≈ p -@testset "TLAS Construction - Multiple Instances" begin - # Create a triangle BLAS - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(1) - ) - - blas = build_blas([tri]) - - # Create two instances with different transforms - identity = Mat4f(I) - translation = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 5, 0, 0, 1 - ) - inv_translation = Mat4f(inv(translation)) - - instances = [ - InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), - InstanceDescriptor(UInt32(1), UInt32(2), translation, inv_translation, UInt32(0)) - ] - - tlas = build_tlas([blas], instances) - - @test tlas isa Raycore.StaticTLAS - @test length(tlas.instances) == 2 - @test length(tlas.blas_descriptors) == 1 - @test length(tlas.nodes) == 3 # 2 leaves + 1 interior = 3 nodes - - # World bound should encompass both instances - wb = world_bound(tlas) - @test wb.p_min[1] ≈ 0.0f0 - @test wb.p_max[1] ≈ 6.0f0 # Original + translated -end + # Test translation + translation = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 5, 10, 15, 1 + ) + p_translated = Raycore.transform_point(translation, p) + @test p_translated ≈ Point3f(6, 12, 18) -# ============================================================================== -# TLAS Ray Intersection Tests -# ============================================================================== + # Test direction transformation (should ignore translation) + v = Vec3f(1, 0, 0) + v_transformed = Raycore.transform_direction(translation, v) + @test v_transformed ≈ v -@testset "TLAS closest_hit - Basic" begin - # Create a unit triangle in XY plane at z=0 - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(42) - ) - - blas = build_blas([tri]) - identity = Mat4f(I) - instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] - tlas = build_tlas([blas], instances) - - # Ray pointing down at center of triangle - ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - hit, prim, dist, bary, inst_id = closest_hit(tlas, ray) - - @test hit == true - @test dist ≈ 1.0f0 - @test prim.metadata == UInt32(42) - - # Ray missing the triangle - ray_miss = Ray(o=Point3f(2, 2, 1.0), d=Vec3f(0, 0, -1)) - hit_miss, _, _, _, _ = closest_hit(tlas, ray_miss) - @test hit_miss == false -end + # Test type stability + @test (@inferred Raycore.transform_point(identity, p)) isa Point3f + @test (@inferred Raycore.transform_direction(identity, v)) isa Vec3f + end -@testset "TLAS closest_hit - Transformed Instance" begin - # Create a unit triangle - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(1) - ) - - blas = build_blas([tri]) - - # Translate instance by (10, 0, 0) - translation = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 10, 0, 0, 1 - ) - inv_translation = Mat4f(inv(translation)) - instances = [InstanceDescriptor(UInt32(1), UInt32(1), translation, inv_translation, UInt32(0))] - - tlas = build_tlas([blas], instances) - - # Ray at original position should miss - ray_miss = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - hit_miss, _, _, _, _ = closest_hit(tlas, ray_miss) - @test hit_miss == false - - # Ray at translated position should hit - ray_hit = Ray(o=Point3f(10.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - hit, _, dist, _, _ = closest_hit(tlas, ray_hit) - @test hit == true - @test dist ≈ 1.0f0 -end + @testset "BVHNode2 Utilities" begin + # Test leaf detection + leaf_node = BVHNode2( + Point3f(0), Point3f(1), + Point3f(0), Point3f(0), + INVALID_NODE, UInt32(5), INVALID_NODE + ) + @test is_leaf(leaf_node) + @test !is_interior(leaf_node) + + # Test interior detection + interior_node = BVHNode2( + Point3f(0), Point3f(1), + Point3f(0), Point3f(1), + UInt32(2), UInt32(3), INVALID_NODE + ) + @test !is_leaf(interior_node) + @test is_interior(interior_node) + + # Test AABB extraction + aabb = Raycore.get_node_aabb(interior_node, true) + @test aabb isa Bounds3 + @test aabb.p_min == Point3f(0, 0, 0) + @test aabb.p_max == Point3f(1, 1, 1) + end -@testset "TLAS closest_hit - Multiple Instances (Closest Selection)" begin - # Create a unit triangle - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(1) - ) - - blas = build_blas([tri]) - identity = Mat4f(I) - - # Two instances: one at z=0, one at z=-5 (further from camera) - translate_back = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 0, 0, -5, 1 - ) - inv_translate_back = Mat4f(inv(translate_back)) - - instances = [ - InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), - InstanceDescriptor(UInt32(1), UInt32(2), translate_back, inv_translate_back, UInt32(0)) - ] - - tlas = build_tlas([blas], instances) - - # Ray should hit the closer one (z=0) - ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - hit, _, dist, _, inst_id = closest_hit(tlas, ray) - - @test hit == true - @test dist ≈ 1.0f0 # Distance to z=0 plane - @test inst_id == UInt32(1) # First instance -end + @testset "AABB Utilities" begin + # Test expand_bits + @test Raycore.expand_bits(UInt32(0)) == UInt32(0) + @test Raycore.expand_bits(UInt32(1)) isa UInt32 -@testset "TLAS all_hits! - Sorted Multiple Hits" begin - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(7) - ) - - blas = build_blas([tri]) - identity = Mat4f(I) - translate_back = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 0, 0, -5, 1 - ) - inv_translate_back = Mat4f(inv(translate_back)) - - instances = [ - InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), - InstanceDescriptor(UInt32(1), UInt32(2), translate_back, inv_translate_back, UInt32(0)) - ] - tlas = build_tlas([blas], instances) - - metadata = fill(UInt32(0), 4) - distances = fill(0.0f0, 4) - ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 4, 0.0f0) - - @test count == Int32(2) - @test overflow == false - @test metadata[1:2] == UInt32[7, 7] - @test distances[1] ≈ 1.0f0 - @test distances[2] ≈ 6.0f0 - - limited_metadata = fill(UInt32(0), 1) - limited_distances = fill(0.0f0, 1) - limited_count, limited_overflow = all_hits!(limited_metadata, limited_distances, tlas, ray, 0, 1, 0.0f0) - @test limited_count == Int32(1) - @test limited_overflow == true - @test limited_metadata[1] == UInt32(7) - @test limited_distances[1] ≈ 1.0f0 -end + # Test clz32 + @test Raycore.clz32(UInt32(0)) == Int32(32) + @test Raycore.clz32(UInt32(1)) == Int32(31) + @test Raycore.clz32(UInt32(0x80000000)) == Int32(0) + end -@testset "TLAS all_hits! - Duplicate Suppression" begin - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(9) - ) - - blas = build_blas([tri, tri]) - identity = Mat4f(I) - instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] - tlas = build_tlas([blas], instances) - - ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - metadata = fill(UInt32(0), 4) - distances = fill(0.0f0, 4) - count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 4, 1.0f-5) - - @test count == Int32(1) - @test overflow == false - @test metadata[1] == UInt32(9) - @test distances[1] ≈ 1.0f0 -end + @testset "Delta Function (LCP)" begin + # Test longest common prefix calculation + codes = UInt32[0x00000001, 0x00000002, 0x00000004, 0x00000008] -@testset "TLAS all_hits! - Overflow Keeps Closest Hits" begin - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(11) - ) - - blas = build_blas([tri]) - identity = Mat4f(I) - translate_back_2 = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 0, 0, -2, 1 - ) - translate_back_5 = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 0, 0, -5, 1 - ) - - instances = [ - InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), - InstanceDescriptor(UInt32(1), UInt32(2), translate_back_2, Mat4f(inv(translate_back_2)), UInt32(0)), - InstanceDescriptor(UInt32(1), UInt32(3), translate_back_5, Mat4f(inv(translate_back_5)), UInt32(0)) - ] - tlas = build_tlas([blas], instances) - - ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - metadata = fill(UInt32(0), 2) - distances = fill(0.0f0, 2) - count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 2, 0.0f0) - - @test count == Int32(2) - @test overflow == true - @test metadata == UInt32[11, 11] - @test distances[1] ≈ 1.0f0 - @test distances[2] ≈ 3.0f0 -end + # Adjacent codes with different prefixes + d1 = Raycore.delta(Int32(1), Int32(2), codes, Int32(4)) + d2 = Raycore.delta(Int32(2), Int32(3), codes, Int32(4)) + + @test d1 isa Int32 + @test d2 isa Int32 -@testset "TLAS all_hits! - KernelAbstractions Kernel" begin - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(13) - ) - - blas = build_blas([tri]) - identity = Mat4f(I) - translate_back = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 0, 0, -5, 1 - ) - tlas = build_tlas( - [blas], - [ + # Out of bounds should return -1 + d_oob = Raycore.delta(Int32(1), Int32(10), codes, Int32(4)) + @test d_oob == Int32(-1) + end + + # ============================================================================== + # TLAS Construction Tests + # ============================================================================== + + @testset "TLAS Construction - Single Instance" begin + # Create a single triangle as BLAS + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(1) + ) + + blas = build_blas([tri]) + identity = Mat4f(I) + instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] + + tlas = build_tlas([blas], instances) + + @test tlas isa Raycore.StaticTLAS + @test length(tlas.instances) == 1 + @test length(tlas.blas_descriptors) == 1 + @test length(tlas.nodes) == 1 # Single instance = 1 node (leaf) + end + + @testset "TLAS Construction - Multiple Instances" begin + # Create a triangle BLAS + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(1) + ) + + blas = build_blas([tri]) + + # Create two instances with different transforms + identity = Mat4f(I) + translation = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 5, 0, 0, 1 + ) + inv_translation = Mat4f(inv(translation)) + + instances = [ InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), - InstanceDescriptor(UInt32(1), UInt32(2), translate_back, Mat4f(inv(translate_back)), UInt32(0)), - ], - ) - - backend = KernelAbstractions.CPU() - n = 2 - origins = KA.allocate(backend, Point3f, n) - directions = KA.allocate(backend, Vec3f, n) - KA.copyto!(backend, origins, [Point3f(0.25, 0.25, 1.0), Point3f(5.0, 5.0, 1.0)]) - KA.copyto!(backend, directions, fill(Vec3f(0, 0, -1), n)) - - max_hits = 2 - metadata = KA.allocate(backend, UInt32, n * max_hits) - distances = KA.allocate(backend, Float32, n * max_hits) - counts = KA.allocate(backend, Int32, n) - overflow = KA.allocate(backend, Bool, n) - - kernel = all_hits_kernel!(backend) - kernel(metadata, distances, counts, overflow, tlas, origins, directions, max_hits, 0.0f0; ndrange=n) - KA.synchronize(backend) - - @test Array(counts) == Int32[2, 0] - @test Array(overflow) == Bool[false, false] - distances_cpu = Array(distances) - @test distances_cpu[1] ≈ 1.0f0 - @test distances_cpu[2] ≈ 6.0f0 - @test Array(metadata)[1:2] == UInt32[13, 13] - - limited_metadata = KA.allocate(backend, UInt32, n) - limited_distances = KA.allocate(backend, Float32, n) - limited_counts = KA.allocate(backend, Int32, n) - limited_overflow = KA.allocate(backend, Bool, n) - kernel(limited_metadata, limited_distances, limited_counts, limited_overflow, tlas, origins, directions, 1, 0.0f0; ndrange=n) - KA.synchronize(backend) - - @test Array(limited_counts) == Int32[1, 0] - @test Array(limited_overflow) == Bool[true, false] - @test Array(limited_distances)[1] ≈ 1.0f0 - @test Array(limited_metadata)[1] == UInt32(13) -end + InstanceDescriptor(UInt32(1), UInt32(2), translation, inv_translation, UInt32(0)) + ] -@testset "TLAS any_hit - Basic" begin - # Create a unit triangle - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(1) - ) - - blas = build_blas([tri]) - identity = Mat4f(I) - instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] - tlas = build_tlas([blas], instances) - - # Ray hitting triangle - ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - hit, _, _, _, _ = any_hit(tlas, ray) - @test hit == true - - # Ray missing triangle - ray_miss = Ray(o=Point3f(2, 2, 1.0), d=Vec3f(0, 0, -1)) - hit_miss, _, _, _, _ = any_hit(tlas, ray_miss) - @test hit_miss == false -end + tlas = build_tlas([blas], instances) -# ============================================================================== -# GB.Mesh TLAS API Tests -# ============================================================================== + @test tlas isa Raycore.StaticTLAS + @test length(tlas.instances) == 2 + @test length(tlas.blas_descriptors) == 1 + @test length(tlas.nodes) == 3 # 2 leaves + 1 interior = 3 nodes -# Helper to create a GB.Mesh with normals -function make_test_mesh(verts, normals) - faces = [GLTriangleFace(1, 2, 3)] - GeometryBasics.mesh(verts, faces; normal=normals) -end + # World bound should encompass both instances + wb = world_bound(tlas) + @test wb.p_min[1] ≈ 0.0f0 + @test wb.p_max[1] ≈ 6.0f0 # Original + translated + end -@testset "TLASHandle and n_instances" begin - mesh1 = make_test_mesh( - [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - mesh2 = make_test_mesh( - [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - - tlas, handles = TLAS([mesh1, mesh2]) - - @test length(handles) == 2 - @test handles[1] isa TLASHandle - @test handles[2] isa TLASHandle - - count1 = n_instances(tlas, handles[1]) - count2 = n_instances(tlas, handles[2]) - - @test count1 == 1 - @test count2 == 1 - @test is_valid(tlas, handles[1]) - @test is_valid(tlas, handles[2]) -end + # ============================================================================== + # TLAS Ray Intersection Tests + # ============================================================================== + + @testset "TLAS closest_hit - Basic" begin + # Create a unit triangle in XY plane at z=0 + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(42) + ) -@testset "TLAS with multi-transform push!" begin - mesh1 = make_test_mesh( - [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - mesh2 = make_test_mesh( - [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - - # Use multi-transform push! for mesh1 (instancing) - transforms = [ - Mat4f(I), - Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2, 0, 0, 1) - ] - - tlas = Raycore.TLAS(KernelAbstractions.CPU()) - h1 = push!(tlas, mesh1, transforms) - h2 = push!(tlas, mesh2) - sync!(tlas) - - @test n_geometries(tlas) == 2 # 2 unique BLAS - @test n_instances(tlas) == 3 # 2 + 1 = 3 instance descriptors - - @test n_instances(tlas, h1) == 2 # First handle has 2 instances - @test n_instances(tlas, h2) == 1 # Second handle has 1 instance -end + blas = build_blas([tri]) + identity = Mat4f(I) + instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] + tlas = build_tlas([blas], instances) -@testset "TLAS from GB.Mesh Vector" begin - mesh1 = make_test_mesh( - [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - mesh2 = make_test_mesh( - [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - - tlas, handles = TLAS([mesh1, mesh2]) - - @test n_geometries(tlas) == 2 - @test n_instances(tlas) == 2 - @test length(handles) == 2 -end + # Ray pointing down at center of triangle + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + hit, prim, dist, bary, inst_id = closest_hit(tlas, ray) -# ============================================================================== -# Dynamic Update Tests -# ============================================================================== + @test hit == true + @test dist ≈ 1.0f0 + @test prim.metadata == UInt32(42) -@testset "update_transform! (single instance)" begin - mesh = make_test_mesh( - [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - - tlas, handles = TLAS([mesh]) - - new_transform = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 10, 0, 0, 1 - ) - update_transform!(tlas, handles[1], new_transform) - - # TLAS stores transforms as Mat3x4f (Vulkan row-major 3×4); compare in - # that form, since `≈` between SMatrix{4,3} and SMatrix{4,4} would - # throw on size mismatch. - @test get_instance(tlas, handles[1]).transform ≈ Raycore.mat4_to_mat3x4(new_transform) -end + # Ray missing the triangle + ray_miss = Ray(o=Point3f(2, 2, 1.0), d=Vec3f(0, 0, -1)) + hit_miss, _, _, _, _ = closest_hit(tlas, ray_miss) + @test hit_miss == false + end + + @testset "TLAS closest_hit - Transformed Instance" begin + # Create a unit triangle + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(1) + ) + + blas = build_blas([tri]) + + # Translate instance by (10, 0, 0) + translation = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 10, 0, 0, 1 + ) + inv_translation = Mat4f(inv(translation)) + instances = [InstanceDescriptor(UInt32(1), UInt32(1), translation, inv_translation, UInt32(0))] + + tlas = build_tlas([blas], instances) -@testset "update_transforms! (multiple instances)" begin - mesh = make_test_mesh( - [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - - # Create with 3 transforms using multi-transform push! - initial_transforms = [Mat4f(I), Mat4f(I), Mat4f(I)] - tlas = Raycore.TLAS(KernelAbstractions.CPU()) - h = push!(tlas, mesh, initial_transforms) - sync!(tlas) - handles = [h] - - # Update all transforms - new_transforms = [ - Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1), - Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2, 0, 0, 1), - Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3, 0, 0, 1) - ] - update_transforms!(tlas, handles[1], new_transforms) - - instances = get_instances(tlas, handles[1]) - for (i, inst) in enumerate(instances) - @test inst.transform ≈ Raycore.mat4_to_mat3x4(new_transforms[i]) + # Ray at original position should miss + ray_miss = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + hit_miss, _, _, _, _ = closest_hit(tlas, ray_miss) + @test hit_miss == false + + # Ray at translated position should hit + ray_hit = Ray(o=Point3f(10.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + hit, _, dist, _, _ = closest_hit(tlas, ray_hit) + @test hit == true + @test dist ≈ 1.0f0 end -end -@testset "push! GB.Mesh" begin - mesh1 = make_test_mesh( - [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - mesh2 = make_test_mesh( - [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) + @testset "TLAS closest_hit - Multiple Instances (Closest Selection)" begin + # Create a unit triangle + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(1) + ) - tlas, handles = TLAS([mesh1]) + blas = build_blas([tri]) + identity = Mat4f(I) - @test n_geometries(tlas) == 1 - @test n_instances(tlas) == 1 + # Two instances: one at z=0, one at z=-5 (further from camera) + translate_back = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ) + inv_translate_back = Mat4f(inv(translate_back)) - # Add new mesh using push! + sync! - new_handle = push!(tlas, mesh2) - sync!(tlas) + instances = [ + InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(2), translate_back, inv_translate_back, UInt32(0)) + ] - @test n_geometries(tlas) == 2 - @test n_instances(tlas) == 2 - @test new_handle isa TLASHandle -end + tlas = build_tlas([blas], instances) -@testset "delete! and sync!" begin - mesh1 = make_test_mesh( - [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) - mesh2 = make_test_mesh( - [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) + # Ray should hit the closer one (z=0) + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + hit, _, dist, _, inst_id = closest_hit(tlas, ray) - tlas, handles = TLAS([mesh1, mesh2]) + @test hit == true + @test dist ≈ 1.0f0 # Distance to z=0 plane + @test inst_id == UInt32(1) # First instance + end - @test n_instances(tlas) == 2 - @test is_valid(tlas, handles[1]) - @test is_valid(tlas, handles[2]) + @testset "TLAS all_hits! - Sorted Multiple Hits" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(7) + ) - deleted = delete!(tlas, handles[1]) - @test deleted == true + blas = build_blas([tri]) + identity = Mat4f(I) + translate_back = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ) + inv_translate_back = Mat4f(inv(translate_back)) - @test !is_valid(tlas, handles[1]) + instances = [ + InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(2), translate_back, inv_translate_back, UInt32(0)) + ] + tlas = build_tlas([blas], instances) + + metadata = fill(UInt32(0), 4) + distances = fill(0.0f0, 4) + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 4, 0.0f0) + + @test count == Int32(2) + @test overflow == false + @test metadata[1:2] == UInt32[7, 7] + @test distances[1] ≈ 1.0f0 + @test distances[2] ≈ 6.0f0 + + limited_metadata = fill(UInt32(0), 1) + limited_distances = fill(0.0f0, 1) + limited_count, limited_overflow = all_hits!(limited_metadata, limited_distances, tlas, ray, 0, 1, 0.0f0) + @test limited_count == Int32(1) + @test limited_overflow == true + @test limited_metadata[1] == UInt32(7) + @test limited_distances[1] ≈ 1.0f0 + end - sync!(tlas) + @testset "TLAS all_hits! - Duplicate Suppression" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(9) + ) - @test n_instances(tlas) == 1 - @test is_valid(tlas, handles[2]) -end + blas = build_blas([tri, tri]) + identity = Mat4f(I) + instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] + tlas = build_tlas([blas], instances) -# ============================================================================== -# Type Stability Tests for TLAS -# ============================================================================== + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + metadata = fill(UInt32(0), 4) + distances = fill(0.0f0, 4) + count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 4, 1.0f-5) -@testset "TLAS Type Stability" begin - # Create triangle with concrete metadata type - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(1) - ) - - blas = build_blas([tri]) - identity = Mat4f(I) - instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] - tlas = build_tlas([blas], instances) - - ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - - # Test type stability of closest_hit - result_type = @inferred closest_hit(tlas, ray) - @test result_type[1] isa Bool - @test result_type[2] isa RTriangle{UInt32} - @test result_type[3] isa Float32 - @test result_type[4] isa SVector{3, Float32} - @test result_type[5] isa UInt32 - - # Test type stability of any_hit - result_type_any = @inferred any_hit(tlas, ray) - @test result_type_any[1] isa Bool -end + @test count == Int32(1) + @test overflow == false + @test metadata[1] == UInt32(9) + @test distances[1] ≈ 1.0f0 + end -@testset "TLAS eltype" begin - # Verify eltype returns the correct triangle type without indexing into arrays - v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) - tri = RTriangle( - SVector(v1, v2, v3), - SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), - SVector(Vec3f(0), Vec3f(0), Vec3f(0)), - SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), - UInt32(1) - ) - - blas = build_blas([tri]) - identity = Mat4f(I) - instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] - tlas = build_tlas([blas], instances) - - @test eltype(tlas) == RTriangle{UInt32} -end + @testset "TLAS all_hits! - Generic Metadata" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + near_meta = (surface=UInt32(21), medium=UInt32(2)) + far_meta = (surface=UInt32(34), medium=UInt32(3)) + tri_near = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + near_meta + ) + tri_far = RTriangle( + SVector( + Point3f(0, 0, -2), + Point3f(1, 0, -2), + Point3f(0, 1, -2), + ), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + far_meta + ) -@testset "n_instances and n_geometries" begin - mesh = make_test_mesh( - [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], - [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] - ) + blas = build_blas([tri_far, tri_near]) + identity = Mat4f(I) + instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] + tlas = build_tlas([blas], instances) + + metadata = fill((surface=UInt32(0), medium=UInt32(0)), 2) + distances = fill(0.0f0, 2) + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 2, 0.0f0) + + @test count == Int32(2) + @test overflow == false + @test metadata == [near_meta, far_meta] + @test distances[1] ≈ 1.0f0 + @test distances[2] ≈ 3.0f0 + end - # 5 instances of same geometry using multi-transform push! - transforms = [Mat4f(I) for _ in 1:5] - tlas = Raycore.TLAS(KernelAbstractions.CPU()) - push!(tlas, mesh, transforms) - sync!(tlas) + @testset "TLAS all_hits! - Overflow Keeps Closest Hits" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(11) + ) - @test n_geometries(tlas) == 1 - @test n_instances(tlas) == 5 -end + blas = build_blas([tri]) + identity = Mat4f(I) + translate_back_2 = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -2, 1 + ) + translate_back_5 = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ) + + instances = [ + InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(2), translate_back_2, Mat4f(inv(translate_back_2)), UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(3), translate_back_5, Mat4f(inv(translate_back_5)), UInt32(0)) + ] + tlas = build_tlas([blas], instances) + + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + metadata = fill(UInt32(0), 2) + distances = fill(0.0f0, 2) + count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 2, 0.0f0) + + @test count == Int32(2) + @test overflow == true + @test metadata == UInt32[11, 11] + @test distances[1] ≈ 1.0f0 + @test distances[2] ≈ 3.0f0 + end + + @testset "TLAS all_hits! - KernelAbstractions Kernel" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(13) + ) + + blas = build_blas([tri]) + identity = Mat4f(I) + translate_back = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ) + tlas = build_tlas( + [blas], + [ + InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(2), translate_back, Mat4f(inv(translate_back)), UInt32(0)), + ], + ) + + backend = KernelAbstractions.CPU() + n = 2 + origins = KernelAbstractions.allocate(backend, Point3f, n) + directions = KernelAbstractions.allocate(backend, Vec3f, n) + KernelAbstractions.copyto!(backend, origins, [Point3f(0.25, 0.25, 1.0), Point3f(5.0, 5.0, 1.0)]) + KernelAbstractions.copyto!(backend, directions, fill(Vec3f(0, 0, -1), n)) + + max_hits = 2 + metadata = KernelAbstractions.allocate(backend, UInt32, n * max_hits) + distances = KernelAbstractions.allocate(backend, Float32, n * max_hits) + counts = KernelAbstractions.allocate(backend, Int32, n) + overflow = KernelAbstractions.allocate(backend, Bool, n) + + kernel = all_hits_kernel!(backend) + kernel(metadata, distances, counts, overflow, tlas, origins, directions, max_hits, 0.0f0; ndrange=n) + KernelAbstractions.synchronize(backend) + + @test Array(counts) == Int32[2, 0] + @test Array(overflow) == Bool[false, false] + distances_cpu = Array(distances) + @test distances_cpu[1] ≈ 1.0f0 + @test distances_cpu[2] ≈ 6.0f0 + @test Array(metadata)[1:2] == UInt32[13, 13] + + limited_metadata = KernelAbstractions.allocate(backend, UInt32, n) + limited_distances = KernelAbstractions.allocate(backend, Float32, n) + limited_counts = KernelAbstractions.allocate(backend, Int32, n) + limited_overflow = KernelAbstractions.allocate(backend, Bool, n) + kernel(limited_metadata, limited_distances, limited_counts, limited_overflow, tlas, origins, directions, 1, 0.0f0; ndrange=n) + KernelAbstractions.synchronize(backend) + + @test Array(limited_counts) == Int32[1, 0] + @test Array(limited_overflow) == Bool[true, false] + @test Array(limited_distances)[1] ≈ 1.0f0 + @test Array(limited_metadata)[1] == UInt32(13) + end + + @testset "TLAS any_hit - Basic" begin + # Create a unit triangle + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(1) + ) + + blas = build_blas([tri]) + identity = Mat4f(I) + instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] + tlas = build_tlas([blas], instances) + + # Ray hitting triangle + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + hit, _, _, _, _ = any_hit(tlas, ray) + @test hit == true + + # Ray missing triangle + ray_miss = Ray(o=Point3f(2, 2, 1.0), d=Vec3f(0, 0, -1)) + hit_miss, _, _, _, _ = any_hit(tlas, ray_miss) + @test hit_miss == false + end + + # ============================================================================== + # GB.Mesh TLAS API Tests + # ============================================================================== + + # Helper to create a GB.Mesh with normals + function make_test_mesh(verts, normals) + faces = [GLTriangleFace(1, 2, 3)] + GeometryBasics.mesh(verts, faces; normal=normals) + end + + @testset "TLASHandle and n_instances" begin + mesh1 = make_test_mesh( + [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + mesh2 = make_test_mesh( + [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + + tlas, handles = TLAS([mesh1, mesh2]) + + @test length(handles) == 2 + @test handles[1] isa TLASHandle + @test handles[2] isa TLASHandle + + count1 = n_instances(tlas, handles[1]) + count2 = n_instances(tlas, handles[2]) + + @test count1 == 1 + @test count2 == 1 + @test is_valid(tlas, handles[1]) + @test is_valid(tlas, handles[2]) + end + + @testset "TLAS with multi-transform push!" begin + mesh1 = make_test_mesh( + [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + mesh2 = make_test_mesh( + [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + + # Use multi-transform push! for mesh1 (instancing) + transforms = [ + Mat4f(I), + Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2, 0, 0, 1) + ] + + tlas = Raycore.TLAS(KernelAbstractions.CPU()) + h1 = push!(tlas, mesh1, transforms) + h2 = push!(tlas, mesh2) + sync!(tlas) + + @test n_geometries(tlas) == 2 # 2 unique BLAS + @test n_instances(tlas) == 3 # 2 + 1 = 3 instance descriptors + + @test n_instances(tlas, h1) == 2 # First handle has 2 instances + @test n_instances(tlas, h2) == 1 # Second handle has 1 instance + end + + @testset "TLAS from GB.Mesh Vector" begin + mesh1 = make_test_mesh( + [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + mesh2 = make_test_mesh( + [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + + tlas, handles = TLAS([mesh1, mesh2]) + + @test n_geometries(tlas) == 2 + @test n_instances(tlas) == 2 + @test length(handles) == 2 + end + + # ============================================================================== + # Dynamic Update Tests + # ============================================================================== + + @testset "update_transform! (single instance)" begin + mesh = make_test_mesh( + [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + + tlas, handles = TLAS([mesh]) + + new_transform = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 10, 0, 0, 1 + ) + update_transform!(tlas, handles[1], new_transform) + + # TLAS stores transforms as Mat3x4f (Vulkan row-major 3×4); compare in + # that form, since `≈` between SMatrix{4,3} and SMatrix{4,4} would + # throw on size mismatch. + @test get_instance(tlas, handles[1]).transform ≈ Raycore.mat4_to_mat3x4(new_transform) + end + + @testset "update_transforms! (multiple instances)" begin + mesh = make_test_mesh( + [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + + # Create with 3 transforms using multi-transform push! + initial_transforms = [Mat4f(I), Mat4f(I), Mat4f(I)] + tlas = Raycore.TLAS(KernelAbstractions.CPU()) + h = push!(tlas, mesh, initial_transforms) + sync!(tlas) + handles = [h] + + # Update all transforms + new_transforms = [ + Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1), + Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2, 0, 0, 1), + Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3, 0, 0, 1) + ] + update_transforms!(tlas, handles[1], new_transforms) + + instances = get_instances(tlas, handles[1]) + for (i, inst) in enumerate(instances) + @test inst.transform ≈ Raycore.mat4_to_mat3x4(new_transforms[i]) + end + end + + @testset "push! GB.Mesh" begin + mesh1 = make_test_mesh( + [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + mesh2 = make_test_mesh( + [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + + tlas, handles = TLAS([mesh1]) + + @test n_geometries(tlas) == 1 + @test n_instances(tlas) == 1 + + # Add new mesh using push! + sync! + new_handle = push!(tlas, mesh2) + sync!(tlas) + + @test n_geometries(tlas) == 2 + @test n_instances(tlas) == 2 + @test new_handle isa TLASHandle + end + + @testset "delete! and sync!" begin + mesh1 = make_test_mesh( + [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + mesh2 = make_test_mesh( + [Point3f(5, 0, 0), Point3f(6, 0, 0), Point3f(5, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + + tlas, handles = TLAS([mesh1, mesh2]) + + @test n_instances(tlas) == 2 + @test is_valid(tlas, handles[1]) + @test is_valid(tlas, handles[2]) + + deleted = delete!(tlas, handles[1]) + @test deleted == true + + @test !is_valid(tlas, handles[1]) + + sync!(tlas) + + @test n_instances(tlas) == 1 + @test is_valid(tlas, handles[2]) + end + + # ============================================================================== + # Type Stability Tests for TLAS + # ============================================================================== + + @testset "TLAS Type Stability" begin + # Create triangle with concrete metadata type + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(1) + ) + + blas = build_blas([tri]) + identity = Mat4f(I) + instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] + tlas = build_tlas([blas], instances) + + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + + # Test type stability of closest_hit + result_type = @inferred closest_hit(tlas, ray) + @test result_type[1] isa Bool + @test result_type[2] isa RTriangle{UInt32} + @test result_type[3] isa Float32 + @test result_type[4] isa SVector{3,Float32} + @test result_type[5] isa UInt32 + + # Test type stability of any_hit + result_type_any = @inferred any_hit(tlas, ray) + @test result_type_any[1] isa Bool + end + + @testset "TLAS eltype" begin + # Verify eltype returns the correct triangle type without indexing into arrays + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(1) + ) + + blas = build_blas([tri]) + identity = Mat4f(I) + instances = [InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0))] + tlas = build_tlas([blas], instances) + + @test eltype(tlas) == RTriangle{UInt32} + end + + @testset "n_instances and n_geometries" begin + mesh = make_test_mesh( + [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + + # 5 instances of same geometry using multi-transform push! + transforms = [Mat4f(I) for _ in 1:5] + tlas = Raycore.TLAS(KernelAbstractions.CPU()) + push!(tlas, mesh, transforms) + sync!(tlas) + + @test n_geometries(tlas) == 1 + @test n_instances(tlas) == 5 + end end # main testset "Instanced BVH" @@ -943,496 +983,502 @@ if Base.JLOptions().check_bounds == 1 # 1 = --check-bounds=yes @test_broken false # skipped: --check-bounds=yes is incompatible with GPU kernel compilation end else -@testset "KernelAbstractions Dynamic Scenes" begin - cl_backend = test_backend() - - # Helper to create a simple GB.Mesh - function make_triangle_mesh(offset::Vec3f=Vec3f(0, 0, 0)) - verts = [ - Point3f(0, 0, 0) + offset, - Point3f(1, 0, 0) + offset, - Point3f(0, 1, 0) + offset - ] - norms = fill(Normal3f(0, 0, 1), 3) - faces = [GLTriangleFace(1, 2, 3)] - return GeometryBasics.mesh(verts, faces; normal=norms) - end - - @testset "TLAS adapt to LavaArray" begin - mesh = make_triangle_mesh() - tlas, handles = TLAS([mesh]; backend=cl_backend) - - # Adapt TLAS to Lava arrays (GPU-first: backend must match) - cl_tlas = Adapt.adapt(cl_backend, tlas) - - @test cl_tlas isa Raycore.StaticTLAS - # GPU arrays (LavaArray) are not isbits on the host — KA handles - # the device pointer conversion during kernel launch. - # The kernel tests below verify that the TLAS works correctly on GPU. - if cl_backend isa KA.CPU - @test cl_tlas.nodes isa Vector - else - @test cl_tlas.nodes isa LavaArray + @testset "KernelAbstractions Dynamic Scenes" begin + cl_backend = test_backend() + + # Helper to create a simple GB.Mesh + function make_triangle_mesh(offset::Vec3f=Vec3f(0, 0, 0)) + verts = [ + Point3f(0, 0, 0) + offset, + Point3f(1, 0, 0) + offset, + Point3f(0, 1, 0) + offset + ] + norms = fill(Normal3f(0, 0, 1), 3) + faces = [GLTriangleFace(1, 2, 3)] + return GeometryBasics.mesh(verts, faces; normal=norms) end - end - - @testset "TLAS sync with many instances" begin - mesh = make_triangle_mesh() - transforms = [Mat4f(1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - Float32(mod(i - 1, 9)) * 1.5f0, - Float32((i - 1) ÷ 9) * 1.25f0, - 0, - 1) for i in 1:81] - - tlas = Raycore.TLAS(cl_backend) - push!(tlas, mesh, transforms) - sync!(tlas) - - @test length(tlas.instances) == 81 - @test length(tlas.nodes) == 161 - @test Raycore.world_bound(tlas) isa Bounds3 - end - - @testset "closest_hit_kernel! - basic intersection" begin - mesh = make_triangle_mesh() - tlas, _ = TLAS([mesh]; backend=cl_backend) - cl_tlas = Adapt.adapt(cl_backend, tlas) - - n = 4 - origins = KA.allocate(cl_backend, Point3f, n) - directions = KA.allocate(cl_backend, Vec3f, n) - hits = KA.allocate(cl_backend, Bool, n) - distances = KA.allocate(cl_backend, Float32, n) - - # Test rays: 2 hits, 2 misses - KA.copyto!(cl_backend, origins, [ - Point3f(0.25, 0.25, 1.0), # hit - Point3f(0.5, 0.25, 1.0), # hit - Point3f(5.0, 5.0, 1.0), # miss - Point3f(-1.0, -1.0, 1.0) # miss - ]) - KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) - - kernel = closest_hit_kernel!(cl_backend) - kernel(hits, distances, cl_tlas, origins, directions; ndrange=n) - KA.synchronize(cl_backend) - - hits_cpu = Array(hits) - distances_cpu = Array(distances) - - @test hits_cpu[1] == true - @test hits_cpu[2] == true - @test hits_cpu[3] == false - @test hits_cpu[4] == false - @test distances_cpu[1] ≈ 1.0f0 - @test distances_cpu[2] ≈ 1.0f0 - end - - @testset "all_hits_kernel! - sorted stacks and overflow" begin - mesh = make_triangle_mesh() - translate_back = Mat4f( - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 0, 0, -5, 1 - ) - tlas = Raycore.TLAS(cl_backend) - push!(tlas, mesh, [Mat4f(I), translate_back]) - sync!(tlas) - cl_tlas = Adapt.adapt(cl_backend, tlas) - - n = 2 - origins = KA.allocate(cl_backend, Point3f, n) - directions = KA.allocate(cl_backend, Vec3f, n) - KA.copyto!(cl_backend, origins, [ - Point3f(0.25, 0.25, 1.0), - Point3f(5.0, 5.0, 1.0), - ]) - KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) - - max_hits = 2 - metadata = KA.allocate(cl_backend, UInt32, n * max_hits) - distances = KA.allocate(cl_backend, Float32, n * max_hits) - counts = KA.allocate(cl_backend, Int32, n) - overflow = KA.allocate(cl_backend, Bool, n) - - kernel = all_hits_kernel!(cl_backend) - kernel(metadata, distances, counts, overflow, cl_tlas, origins, directions, max_hits, 0.0f0; ndrange=n) - KA.synchronize(cl_backend) - - counts_cpu = Array(counts) - overflow_cpu = Array(overflow) - distances_cpu = Array(distances) - @test counts_cpu == Int32[2, 0] - @test overflow_cpu == Bool[false, false] - @test distances_cpu[1] ≈ 1.0f0 - @test distances_cpu[2] ≈ 6.0f0 - - limited_metadata = KA.allocate(cl_backend, UInt32, n) - limited_distances = KA.allocate(cl_backend, Float32, n) - limited_counts = KA.allocate(cl_backend, Int32, n) - limited_overflow = KA.allocate(cl_backend, Bool, n) - kernel(limited_metadata, limited_distances, limited_counts, limited_overflow, cl_tlas, origins, directions, 1, 0.0f0; ndrange=n) - KA.synchronize(cl_backend) - @test Array(limited_counts) == Int32[1, 0] - @test Array(limited_overflow) == Bool[true, false] - @test Array(limited_distances)[1] ≈ 1.0f0 - end - - @testset "any_hit_kernel! - shadow/occlusion test" begin - mesh = make_triangle_mesh() - tlas, _ = TLAS([mesh]; backend=cl_backend) - cl_tlas = Adapt.adapt(cl_backend, tlas) - - n = 4 - origins = KA.allocate(cl_backend, Point3f, n) - directions = KA.allocate(cl_backend, Vec3f, n) - hits = KA.allocate(cl_backend, Bool, n) - - # Test rays - KA.copyto!(cl_backend, origins, [ - Point3f(0.25, 0.25, 1.0), # hit - Point3f(0.1, 0.1, 1.0), # hit - Point3f(5.0, 5.0, 1.0), # miss - Point3f(0.9, 0.9, 1.0) # miss (outside triangle) - ]) - KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) - - kernel = any_hit_kernel!(cl_backend) - kernel(hits, cl_tlas, origins, directions; ndrange=n) - KA.synchronize(cl_backend) - - hits_cpu = Array(hits) - @test hits_cpu[1] == true - @test hits_cpu[2] == true - @test hits_cpu[3] == false - @test hits_cpu[4] == false - end - - @testset "closest_hit_instance_id_kernel! - instance identification" begin - mesh = make_triangle_mesh() - - # Three instances at different positions. Traversal returns the - # 1-based instance array index; here we push 3 instances so each - # ray hits position 1, 2, 3. - transforms = [ - Mat4f(I), # Instance 1 at origin - Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 0, 0, 1), # Instance 2 at x=5 - Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 5, 0, 1) # Instance 3 at y=5 - ] - tlas_tmp = Raycore.TLAS(cl_backend) - push!(tlas_tmp, mesh, transforms) - sync!(tlas_tmp) - tlas = tlas_tmp - cl_tlas = Adapt.adapt(cl_backend, tlas) - - n = 3 - origins = KA.allocate(cl_backend, Point3f, n) - directions = KA.allocate(cl_backend, Vec3f, n) - hits = KA.allocate(cl_backend, Bool, n) - distances = KA.allocate(cl_backend, Float32, n) - instance_ids = KA.allocate(cl_backend, UInt32, n) - - # Each ray targets a different instance - KA.copyto!(cl_backend, origins, [ - Point3f(0.25, 0.25, 1.0), # hits instance 1 - Point3f(5.25, 0.25, 1.0), # hits instance 2 - Point3f(0.25, 5.25, 1.0) # hits instance 3 - ]) - KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) - - kernel = closest_hit_instance_id_kernel!(cl_backend) - kernel(hits, distances, instance_ids, cl_tlas, origins, directions; ndrange=n) - KA.synchronize(cl_backend) - - hits_cpu = Array(hits) - instance_ids_cpu = Array(instance_ids) - - @test all(hits_cpu) - # closest_hit returns the 1-based instance array index - @test instance_ids_cpu[1] == UInt32(1) - @test instance_ids_cpu[2] == UInt32(2) - @test instance_ids_cpu[3] == UInt32(3) - end + @testset "TLAS adapt to LavaArray" begin + mesh = make_triangle_mesh() + tlas, handles = TLAS([mesh]; backend=cl_backend) + + # Adapt TLAS to Lava arrays (GPU-first: backend must match) + cl_tlas = Adapt.adapt(cl_backend, tlas) + + @test cl_tlas isa Raycore.StaticTLAS + # GPU arrays (LavaArray) are not isbits on the host — KA handles + # the device pointer conversion during kernel launch. + # The kernel tests below verify that the TLAS works correctly on GPU. + if cl_backend isa KA.CPU + @test cl_tlas.nodes isa Vector + else + @test cl_tlas.nodes isa LavaArray + end + end - @testset "closest_hit_metadata_kernel! - primitive metadata" begin - # Create meshes at different positions (metadata test simplified - mesh default is 0) - mesh1 = make_triangle_mesh(Vec3f(0, 0, 0)) - mesh2 = make_triangle_mesh(Vec3f(5, 0, 0)) - mesh3 = make_triangle_mesh(Vec3f(0, 5, 0)) - - tlas, _ = TLAS([mesh1, mesh2, mesh3]; backend=cl_backend) - cl_tlas = Adapt.adapt(cl_backend, tlas) - - n = 4 - origins = KA.allocate(cl_backend, Point3f, n) - directions = KA.allocate(cl_backend, Vec3f, n) - hits = KA.allocate(cl_backend, Bool, n) - metadata_out = KA.allocate(cl_backend, UInt32, n) - - KA.copyto!(cl_backend, origins, [ - Point3f(0.25, 0.25, 1.0), # hits mesh1 - Point3f(5.25, 0.25, 1.0), # hits mesh2 - Point3f(0.25, 5.25, 1.0), # hits mesh3 - Point3f(10.0, 10.0, 1.0) # miss - ]) - KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) - - kernel = closest_hit_metadata_kernel!(cl_backend) - kernel(hits, metadata_out, cl_tlas, origins, directions; ndrange=n) - KA.synchronize(cl_backend) - - hits_cpu = Array(hits) - - @test hits_cpu[1] == true - @test hits_cpu[2] == true - @test hits_cpu[3] == true - @test hits_cpu[4] == false - # Note: metadata from mesh is 0 by default, so we just test hits work - end + @testset "TLAS sync with many instances" begin + mesh = make_triangle_mesh() + transforms = [Mat4f(1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + Float32(mod(i - 1, 9)) * 1.5f0, + Float32((i - 1) ÷ 9) * 1.25f0, + 0, + 1) for i in 1:81] + + tlas = Raycore.TLAS(cl_backend) + push!(tlas, mesh, transforms) + sync!(tlas) + + @test length(tlas.instances) == 81 + @test length(tlas.nodes) == 161 + @test Raycore.world_bound(tlas) isa Bounds3 + end - @testset "closest_hit_bary_kernel! - barycentric coordinates" begin - mesh = make_triangle_mesh() - tlas, _ = TLAS([mesh]; backend=cl_backend) - cl_tlas = Adapt.adapt(cl_backend, tlas) - - n = 3 - origins = KA.allocate(cl_backend, Point3f, n) - directions = KA.allocate(cl_backend, Vec3f, n) - hits = KA.allocate(cl_backend, Bool, n) - barys = KA.allocate(cl_backend, SVector{3, Float32}, n) - - # Triangle vertices: (0,0,0), (1,0,0), (0,1,0) - # Hit points chosen to give predictable barycentrics - KA.copyto!(cl_backend, origins, [ - Point3f(0.25, 0.25, 1.0), # should give bary ≈ (0.25, 0.25, 0.5) - Point3f(0.1, 0.1, 1.0), # should give bary ≈ (0.1, 0.1, 0.8) - Point3f(0.5, 0.0, 1.0) # edge hit, bary ≈ (0.5, 0.0, 0.5) - ]) - KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) - - kernel = closest_hit_bary_kernel!(cl_backend) - kernel(hits, barys, cl_tlas, origins, directions; ndrange=n) - KA.synchronize(cl_backend) - - hits_cpu = Array(hits) - barys_cpu = Array(barys) - - @test all(hits_cpu) - # Barycentrics are (w, u, v) where w = 1-u-v - # For hit at (0.25, 0.25): u=0.25, v=0.25, w=0.5 - @test barys_cpu[1][1] ≈ 0.5f0 atol=0.01 # w - @test barys_cpu[1][2] ≈ 0.25f0 atol=0.01 # u - # For hit at (0.1, 0.1): u=0.1, v=0.1, w=0.8 - @test barys_cpu[2][1] ≈ 0.8f0 atol=0.01 # w - @test barys_cpu[2][2] ≈ 0.1f0 atol=0.01 # u - # For edge hit at (0.5, 0.0): u=0.5, v=0.0, w=0.5 - @test barys_cpu[3][1] ≈ 0.5f0 atol=0.01 # w - @test barys_cpu[3][2] ≈ 0.5f0 atol=0.01 # u - end + @testset "closest_hit_kernel! - basic intersection" begin + mesh = make_triangle_mesh() + tlas, _ = TLAS([mesh]; backend=cl_backend) + cl_tlas = Adapt.adapt(cl_backend, tlas) + + n = 4 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + hits = KA.allocate(cl_backend, Bool, n) + distances = KA.allocate(cl_backend, Float32, n) + + # Test rays: 2 hits, 2 misses + KA.copyto!(cl_backend, origins, [ + Point3f(0.25, 0.25, 1.0), # hit + Point3f(0.5, 0.25, 1.0), # hit + Point3f(5.0, 5.0, 1.0), # miss + Point3f(-1.0, -1.0, 1.0) # miss + ]) + KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) + + kernel = closest_hit_kernel!(cl_backend) + kernel(hits, distances, cl_tlas, origins, directions; ndrange=n) + KA.synchronize(cl_backend) + + hits_cpu = Array(hits) + distances_cpu = Array(distances) + + @test hits_cpu[1] == true + @test hits_cpu[2] == true + @test hits_cpu[3] == false + @test hits_cpu[4] == false + @test distances_cpu[1] ≈ 1.0f0 + @test distances_cpu[2] ≈ 1.0f0 + end - @testset "full_trace_kernel! - comprehensive output" begin - mesh1 = make_triangle_mesh(Vec3f(0, 0, 0)) - mesh2 = make_triangle_mesh(Vec3f(5, 0, 0)) - - # Two default-override (inherit) instances; closest_hit returns - # their 1-based array positions (1 and 2). - tlas, _ = TLAS([mesh1, mesh2]; backend=cl_backend) - cl_tlas = Adapt.adapt(cl_backend, tlas) - - n = 3 - origins = KA.allocate(cl_backend, Point3f, n) - directions = KA.allocate(cl_backend, Vec3f, n) - hits = KA.allocate(cl_backend, Bool, n) - distances = KA.allocate(cl_backend, Float32, n) - instance_ids = KA.allocate(cl_backend, UInt32, n) - metadata_out = KA.allocate(cl_backend, UInt32, n) - barys = KA.allocate(cl_backend, SVector{3, Float32}, n) - - KA.copyto!(cl_backend, origins, [ - Point3f(0.25, 0.25, 2.0), # hits mesh1 at dist=2 - Point3f(5.25, 0.25, 3.0), # hits mesh2 at dist=3 - Point3f(10.0, 10.0, 1.0) # miss - ]) - KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) - - kernel = full_trace_kernel!(cl_backend) - kernel(hits, distances, instance_ids, metadata_out, barys, cl_tlas, origins, directions; ndrange=n) - KA.synchronize(cl_backend) - - hits_cpu = Array(hits) - distances_cpu = Array(distances) - instance_ids_cpu = Array(instance_ids) - barys_cpu = Array(barys) + @testset "all_hits_kernel! - sorted stacks and overflow" begin + mesh = make_triangle_mesh() + translate_back = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ) + tlas = Raycore.TLAS(cl_backend) + push!(tlas, mesh, [Mat4f(I), translate_back]) + sync!(tlas) + cl_tlas = Adapt.adapt(cl_backend, tlas) + + n = 2 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + KA.copyto!(cl_backend, origins, [ + Point3f(0.25, 0.25, 1.0), + Point3f(5.0, 5.0, 1.0), + ]) + KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) + + max_hits = 2 + metadata = KA.allocate(cl_backend, UInt32, n * max_hits) + distances = KA.allocate(cl_backend, Float32, n * max_hits) + counts = KA.allocate(cl_backend, Int32, n) + overflow = KA.allocate(cl_backend, Bool, n) + + kernel = all_hits_kernel!(cl_backend) + kernel(metadata, distances, counts, overflow, cl_tlas, origins, directions, max_hits, 0.0f0; ndrange=n) + KA.synchronize(cl_backend) + + counts_cpu = Array(counts) + overflow_cpu = Array(overflow) + distances_cpu = Array(distances) + @test counts_cpu == Int32[2, 0] + @test overflow_cpu == Bool[false, false] + @test distances_cpu[1] ≈ 1.0f0 + @test distances_cpu[2] ≈ 6.0f0 + + limited_metadata = KA.allocate(cl_backend, UInt32, n) + limited_distances = KA.allocate(cl_backend, Float32, n) + limited_counts = KA.allocate(cl_backend, Int32, n) + limited_overflow = KA.allocate(cl_backend, Bool, n) + kernel(limited_metadata, limited_distances, limited_counts, limited_overflow, cl_tlas, origins, directions, 1, 0.0f0; ndrange=n) + KA.synchronize(cl_backend) + + @test Array(limited_counts) == Int32[1, 0] + @test Array(limited_overflow) == Bool[true, false] + @test Array(limited_distances)[1] ≈ 1.0f0 + end - @test hits_cpu[1] == true - @test hits_cpu[2] == true - @test hits_cpu[3] == false + @testset "any_hit_kernel! - shadow/occlusion test" begin + mesh = make_triangle_mesh() + tlas, _ = TLAS([mesh]; backend=cl_backend) + cl_tlas = Adapt.adapt(cl_backend, tlas) + + n = 4 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + hits = KA.allocate(cl_backend, Bool, n) + + # Test rays + KA.copyto!(cl_backend, origins, [ + Point3f(0.25, 0.25, 1.0), # hit + Point3f(0.1, 0.1, 1.0), # hit + Point3f(5.0, 5.0, 1.0), # miss + Point3f(0.9, 0.9, 1.0) # miss (outside triangle) + ]) + KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) + + kernel = any_hit_kernel!(cl_backend) + kernel(hits, cl_tlas, origins, directions; ndrange=n) + KA.synchronize(cl_backend) + + hits_cpu = Array(hits) + @test hits_cpu[1] == true + @test hits_cpu[2] == true + @test hits_cpu[3] == false + @test hits_cpu[4] == false + end - @test distances_cpu[1] ≈ 2.0f0 - @test distances_cpu[2] ≈ 3.0f0 + @testset "closest_hit_instance_id_kernel! - instance identification" begin + mesh = make_triangle_mesh() + + # Three instances at different positions. Traversal returns the + # 1-based instance array index; here we push 3 instances so each + # ray hits position 1, 2, 3. + transforms = [ + Mat4f(I), # Instance 1 at origin + Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 0, 0, 1), # Instance 2 at x=5 + Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 5, 0, 1) # Instance 3 at y=5 + ] + tlas_tmp = Raycore.TLAS(cl_backend) + push!(tlas_tmp, mesh, transforms) + sync!(tlas_tmp) + tlas = tlas_tmp + cl_tlas = Adapt.adapt(cl_backend, tlas) + + n = 3 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + hits = KA.allocate(cl_backend, Bool, n) + distances = KA.allocate(cl_backend, Float32, n) + instance_ids = KA.allocate(cl_backend, UInt32, n) + + # Each ray targets a different instance + KA.copyto!(cl_backend, origins, [ + Point3f(0.25, 0.25, 1.0), # hits instance 1 + Point3f(5.25, 0.25, 1.0), # hits instance 2 + Point3f(0.25, 5.25, 1.0) # hits instance 3 + ]) + KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) + + kernel = closest_hit_instance_id_kernel!(cl_backend) + kernel(hits, distances, instance_ids, cl_tlas, origins, directions; ndrange=n) + KA.synchronize(cl_backend) + + hits_cpu = Array(hits) + instance_ids_cpu = Array(instance_ids) + + @test all(hits_cpu) + # closest_hit returns the 1-based instance array index + @test instance_ids_cpu[1] == UInt32(1) + @test instance_ids_cpu[2] == UInt32(2) + @test instance_ids_cpu[3] == UInt32(3) + end - @test instance_ids_cpu[1] == UInt32(1) - @test instance_ids_cpu[2] == UInt32(2) + @testset "closest_hit_metadata_kernel! - primitive metadata" begin + # Create meshes at different positions (metadata test simplified - mesh default is 0) + mesh1 = make_triangle_mesh(Vec3f(0, 0, 0)) + mesh2 = make_triangle_mesh(Vec3f(5, 0, 0)) + mesh3 = make_triangle_mesh(Vec3f(0, 5, 0)) + + tlas, _ = TLAS([mesh1, mesh2, mesh3]; backend=cl_backend) + cl_tlas = Adapt.adapt(cl_backend, tlas) + + n = 4 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + hits = KA.allocate(cl_backend, Bool, n) + metadata_out = KA.allocate(cl_backend, UInt32, n) + + KA.copyto!(cl_backend, origins, [ + Point3f(0.25, 0.25, 1.0), # hits mesh1 + Point3f(5.25, 0.25, 1.0), # hits mesh2 + Point3f(0.25, 5.25, 1.0), # hits mesh3 + Point3f(10.0, 10.0, 1.0) # miss + ]) + KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) + + kernel = closest_hit_metadata_kernel!(cl_backend) + kernel(hits, metadata_out, cl_tlas, origins, directions; ndrange=n) + KA.synchronize(cl_backend) + + hits_cpu = Array(hits) + + @test hits_cpu[1] == true + @test hits_cpu[2] == true + @test hits_cpu[3] == true + @test hits_cpu[4] == false + # Note: metadata from mesh is 0 by default, so we just test hits work + end - # Barycentrics are (w, u, v) where w = 1-u-v - # For hit at (0.25, 0.25): u=0.25, v=0.25, w=0.5 - @test barys_cpu[1][1] ≈ 0.5f0 atol=0.01 # w - @test barys_cpu[2][1] ≈ 0.5f0 atol=0.01 # w - end + @testset "closest_hit_bary_kernel! - barycentric coordinates" begin + mesh = make_triangle_mesh() + tlas, _ = TLAS([mesh]; backend=cl_backend) + cl_tlas = Adapt.adapt(cl_backend, tlas) + + n = 3 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + hits = KA.allocate(cl_backend, Bool, n) + barys = KA.allocate(cl_backend, SVector{3,Float32}, n) + + # Triangle vertices: (0,0,0), (1,0,0), (0,1,0) + # Hit points chosen to give predictable barycentrics + KA.copyto!(cl_backend, origins, [ + Point3f(0.25, 0.25, 1.0), # should give bary ≈ (0.25, 0.25, 0.5) + Point3f(0.1, 0.1, 1.0), # should give bary ≈ (0.1, 0.1, 0.8) + Point3f(0.5, 0.0, 1.0) # edge hit, bary ≈ (0.5, 0.0, 0.5) + ]) + KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) + + kernel = closest_hit_bary_kernel!(cl_backend) + kernel(hits, barys, cl_tlas, origins, directions; ndrange=n) + KA.synchronize(cl_backend) + + hits_cpu = Array(hits) + barys_cpu = Array(barys) + + @test all(hits_cpu) + # Barycentrics are (w, u, v) where w = 1-u-v + # For hit at (0.25, 0.25): u=0.25, v=0.25, w=0.5 + @test barys_cpu[1][1] ≈ 0.5f0 atol=0.01 # w + @test barys_cpu[1][2] ≈ 0.25f0 atol=0.01 # u + # For hit at (0.1, 0.1): u=0.1, v=0.1, w=0.8 + @test barys_cpu[2][1] ≈ 0.8f0 atol=0.01 # w + @test barys_cpu[2][2] ≈ 0.1f0 atol=0.01 # u + # For edge hit at (0.5, 0.0): u=0.5, v=0.0, w=0.5 + @test barys_cpu[3][1] ≈ 0.5f0 atol=0.01 # w + @test barys_cpu[3][2] ≈ 0.5f0 atol=0.01 # u + end - @testset "Dynamic transform updates via kernel" begin - mesh = make_triangle_mesh() - - # Create mutable TLAS with backend for dynamic updates - tlas = Raycore.TLAS(cl_backend) - handle = push!(tlas, mesh) - Raycore.sync!(tlas) - - # Initial position: ray at origin should hit - cl_tlas1 = Adapt.adapt(cl_backend, tlas) - - n = 1 - origins = KA.allocate(cl_backend, Point3f, n) - directions = KA.allocate(cl_backend, Vec3f, n) - hits = KA.allocate(cl_backend, Bool, n) - distances = KA.allocate(cl_backend, Float32, n) - - KA.copyto!(cl_backend, origins, [Point3f(0.25, 0.25, 1.0)]) - KA.copyto!(cl_backend, directions, [Vec3f(0, 0, -1)]) - - kernel = closest_hit_kernel!(cl_backend) - kernel(hits, distances, cl_tlas1, origins, directions; ndrange=n) - KA.synchronize(cl_backend) - @test Array(hits)[1] == true - - # Update transform: move to x=10 - new_transform = Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 0, 0, 1) - Raycore.update_transform!(tlas, handle, new_transform) - Raycore.sync!(tlas) - - # Adapt again after update - cl_tlas2 = Adapt.adapt(cl_backend, tlas) - - # Now ray at origin should miss - kernel(hits, distances, cl_tlas2, origins, directions; ndrange=n) - KA.synchronize(cl_backend) - @test Array(hits)[1] == false - - # Ray at x=10 should hit - KA.copyto!(cl_backend, origins, [Point3f(10.25, 0.25, 1.0)]) - kernel(hits, distances, cl_tlas2, origins, directions; ndrange=n) - KA.synchronize(cl_backend) - @test Array(hits)[1] == true - @test Array(distances)[1] ≈ 1.0f0 - end + @testset "full_trace_kernel! - comprehensive output" begin + mesh1 = make_triangle_mesh(Vec3f(0, 0, 0)) + mesh2 = make_triangle_mesh(Vec3f(5, 0, 0)) + + # Two default-override (inherit) instances; closest_hit returns + # their 1-based array positions (1 and 2). + tlas, _ = TLAS([mesh1, mesh2]; backend=cl_backend) + cl_tlas = Adapt.adapt(cl_backend, tlas) + + n = 3 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + hits = KA.allocate(cl_backend, Bool, n) + distances = KA.allocate(cl_backend, Float32, n) + instance_ids = KA.allocate(cl_backend, UInt32, n) + metadata_out = KA.allocate(cl_backend, UInt32, n) + barys = KA.allocate(cl_backend, SVector{3,Float32}, n) + + KA.copyto!(cl_backend, origins, [ + Point3f(0.25, 0.25, 2.0), # hits mesh1 at dist=2 + Point3f(5.25, 0.25, 3.0), # hits mesh2 at dist=3 + Point3f(10.0, 10.0, 1.0) # miss + ]) + KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) + + kernel = full_trace_kernel!(cl_backend) + kernel(hits, distances, instance_ids, metadata_out, barys, cl_tlas, origins, directions; ndrange=n) + KA.synchronize(cl_backend) + + hits_cpu = Array(hits) + distances_cpu = Array(distances) + instance_ids_cpu = Array(instance_ids) + barys_cpu = Array(barys) + + @test hits_cpu[1] == true + @test hits_cpu[2] == true + @test hits_cpu[3] == false + + @test distances_cpu[1] ≈ 2.0f0 + @test distances_cpu[2] ≈ 3.0f0 + + @test instance_ids_cpu[1] == UInt32(1) + @test instance_ids_cpu[2] == UInt32(2) + + # Barycentrics are (w, u, v) where w = 1-u-v + # For hit at (0.25, 0.25): u=0.25, v=0.25, w=0.5 + @test barys_cpu[1][1] ≈ 0.5f0 atol=0.01 # w + @test barys_cpu[2][1] ≈ 0.5f0 atol=0.01 # w + end - @testset "Dynamic scene: add instances via kernel" begin - mesh1 = make_triangle_mesh() - mesh2 = make_triangle_mesh(Vec3f(5, 0, 0)) + @testset "Dynamic transform updates via kernel" begin + mesh = make_triangle_mesh() + + # Create mutable TLAS with backend for dynamic updates + tlas = Raycore.TLAS(cl_backend) + handle = push!(tlas, mesh) + Raycore.sync!(tlas) + + # Initial position: ray at origin should hit + cl_tlas1 = Adapt.adapt(cl_backend, tlas) + + n = 1 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + hits = KA.allocate(cl_backend, Bool, n) + distances = KA.allocate(cl_backend, Float32, n) + + KA.copyto!(cl_backend, origins, [Point3f(0.25, 0.25, 1.0)]) + KA.copyto!(cl_backend, directions, [Vec3f(0, 0, -1)]) + + kernel = closest_hit_kernel!(cl_backend) + kernel(hits, distances, cl_tlas1, origins, directions; ndrange=n) + KA.synchronize(cl_backend) + @test Array(hits)[1] == true + + # Update transform: move to x=10 + new_transform = Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 0, 0, 1) + Raycore.update_transform!(tlas, handle, new_transform) + Raycore.sync!(tlas) + + # Adapt again after update + cl_tlas2 = Adapt.adapt(cl_backend, tlas) + + # Now ray at origin should miss + kernel(hits, distances, cl_tlas2, origins, directions; ndrange=n) + KA.synchronize(cl_backend) + @test Array(hits)[1] == false + + # Ray at x=10 should hit + KA.copyto!(cl_backend, origins, [Point3f(10.25, 0.25, 1.0)]) + kernel(hits, distances, cl_tlas2, origins, directions; ndrange=n) + KA.synchronize(cl_backend) + @test Array(hits)[1] == true + @test Array(distances)[1] ≈ 1.0f0 + end - # Create mutable TLAS - tlas = Raycore.TLAS(cl_backend) - h1 = push!(tlas, mesh1) - Raycore.sync!(tlas) + @testset "Dynamic scene: add instances via kernel" begin + mesh1 = make_triangle_mesh() + mesh2 = make_triangle_mesh(Vec3f(5, 0, 0)) - n = 2 - origins = KA.allocate(cl_backend, Point3f, n) - directions = KA.allocate(cl_backend, Vec3f, n) - hits = KA.allocate(cl_backend, Bool, n) - distances = KA.allocate(cl_backend, Float32, n) + # Create mutable TLAS + tlas = Raycore.TLAS(cl_backend) + h1 = push!(tlas, mesh1) + Raycore.sync!(tlas) - KA.copyto!(cl_backend, origins, [Point3f(0.25, 0.25, 1.0), Point3f(5.25, 0.25, 1.0)]) - KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) + n = 2 + origins = KA.allocate(cl_backend, Point3f, n) + directions = KA.allocate(cl_backend, Vec3f, n) + hits = KA.allocate(cl_backend, Bool, n) + distances = KA.allocate(cl_backend, Float32, n) - kernel = closest_hit_kernel!(cl_backend) + KA.copyto!(cl_backend, origins, [Point3f(0.25, 0.25, 1.0), Point3f(5.25, 0.25, 1.0)]) + KA.copyto!(cl_backend, directions, fill(Vec3f(0, 0, -1), n)) - # Test with just first instance - cl_tlas1 = Adapt.adapt(cl_backend, tlas) - kernel(hits, distances, cl_tlas1, origins, directions; ndrange=n) - KA.synchronize(cl_backend) + kernel = closest_hit_kernel!(cl_backend) - hits_cpu = Array(hits) - @test hits_cpu[1] == true # first mesh - @test hits_cpu[2] == false # second mesh not added yet + # Test with just first instance + cl_tlas1 = Adapt.adapt(cl_backend, tlas) + kernel(hits, distances, cl_tlas1, origins, directions; ndrange=n) + KA.synchronize(cl_backend) - # Add second instance - h2 = push!(tlas, mesh2) - Raycore.sync!(tlas) + hits_cpu = Array(hits) + @test hits_cpu[1] == true # first mesh + @test hits_cpu[2] == false # second mesh not added yet - # Test again with both instances - cl_tlas2 = Adapt.adapt(cl_backend, tlas) - kernel(hits, distances, cl_tlas2, origins, directions; ndrange=n) - KA.synchronize(cl_backend) + # Add second instance + h2 = push!(tlas, mesh2) + Raycore.sync!(tlas) - hits_cpu = Array(hits) - @test hits_cpu[1] == true # first mesh - @test hits_cpu[2] == true # second mesh now present - end + # Test again with both instances + cl_tlas2 = Adapt.adapt(cl_backend, tlas) + kernel(hits, distances, cl_tlas2, origins, directions; ndrange=n) + KA.synchronize(cl_backend) - @testset "Batch ray tracing via kernel (64 rays)" begin - mesh = make_triangle_mesh() - tlas, _ = TLAS([mesh]; backend=cl_backend) - cl_tlas = Adapt.adapt(cl_backend, tlas) + hits_cpu = Array(hits) + @test hits_cpu[1] == true # first mesh + @test hits_cpu[2] == true # second mesh now present + end - # Create batch of rays - n_rays = 64 - origins_vec = [Point3f(0.25 + 0.5*(i % 8)/7, 0.25 + 0.5*((i ÷ 8) % 8)/7, 1.0) for i in 0:n_rays-1] - directions_vec = fill(Vec3f(0, 0, -1), n_rays) + @testset "Batch ray tracing via kernel (64 rays)" begin + mesh = make_triangle_mesh() + tlas, _ = TLAS([mesh]; backend=cl_backend) + cl_tlas = Adapt.adapt(cl_backend, tlas) - origins = KA.allocate(cl_backend, Point3f, n_rays) - directions = KA.allocate(cl_backend, Vec3f, n_rays) - hits = KA.allocate(cl_backend, Bool, n_rays) - distances = KA.allocate(cl_backend, Float32, n_rays) + # Create batch of rays + n_rays = 64 + origins_vec = [Point3f(0.25 + 0.5*(i % 8)/7, 0.25 + 0.5*((i ÷ 8) % 8)/7, 1.0) for i in 0:(n_rays-1)] + directions_vec = fill(Vec3f(0, 0, -1), n_rays) - KA.copyto!(cl_backend, origins, origins_vec) - KA.copyto!(cl_backend, directions, directions_vec) + origins = KA.allocate(cl_backend, Point3f, n_rays) + directions = KA.allocate(cl_backend, Vec3f, n_rays) + hits = KA.allocate(cl_backend, Bool, n_rays) + distances = KA.allocate(cl_backend, Float32, n_rays) - kernel = closest_hit_kernel!(cl_backend) - kernel(hits, distances, cl_tlas, origins, directions; ndrange=n_rays) - KA.synchronize(cl_backend) + KA.copyto!(cl_backend, origins, origins_vec) + KA.copyto!(cl_backend, directions, directions_vec) - hits_cpu = Array(hits) - n_hits = count(hits_cpu) - @test n_hits > 0 # At least some hits - @test n_hits < n_rays # Some misses near edges - end + kernel = closest_hit_kernel!(cl_backend) + kernel(hits, distances, cl_tlas, origins, directions; ndrange=n_rays) + KA.synchronize(cl_backend) - @testset "StaticTLAS field types after adapt" begin - mesh = make_triangle_mesh() - tlas, _ = TLAS([mesh]; backend=cl_backend) - - cl_tlas = Adapt.adapt(cl_backend, tlas) - - # Verify fields land on the right backend after adapt. - ArrayType = cl_backend isa KA.CPU ? Vector : LavaArray - @test cl_tlas.nodes isa ArrayType - @test cl_tlas.instances isa ArrayType - @test cl_tlas.all_blas_nodes isa ArrayType - @test cl_tlas.all_blas_prims isa ArrayType - @test cl_tlas.blas_descriptors isa ArrayType - # root_aabb stays isbits (not an array) - @test isbitstype(typeof(cl_tlas.root_aabb)) - end + hits_cpu = Array(hits) + n_hits = count(hits_cpu) + @test n_hits > 0 # At least some hits + @test n_hits < n_rays # Some misses near edges + end - @testset "World bound preserved after adapt" begin - mesh = make_triangle_mesh() - transforms = [ - Mat4f(I), - Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 10, 0, 1) - ] - tlas, _ = begin; tlas_tmp = Raycore.TLAS(cl_backend); push!(tlas_tmp, mesh, transforms); sync!(tlas_tmp); (tlas_tmp, [TLASHandle(UInt32(1))]); end + @testset "StaticTLAS field types after adapt" begin + mesh = make_triangle_mesh() + tlas, _ = TLAS([mesh]; backend=cl_backend) + + cl_tlas = Adapt.adapt(cl_backend, tlas) + + # Verify fields land on the right backend after adapt. + ArrayType = cl_backend isa KA.CPU ? Vector : LavaArray + @test cl_tlas.nodes isa ArrayType + @test cl_tlas.instances isa ArrayType + @test cl_tlas.all_blas_nodes isa ArrayType + @test cl_tlas.all_blas_prims isa ArrayType + @test cl_tlas.blas_descriptors isa ArrayType + # root_aabb stays isbits (not an array) + @test isbitstype(typeof(cl_tlas.root_aabb)) + end - gpu_bound = tlas.root_aabb - cl_tlas = Adapt.adapt(cl_backend, tlas) - cl_bound = cl_tlas.root_aabb + @testset "World bound preserved after adapt" begin + mesh = make_triangle_mesh() + transforms = [ + Mat4f(I), + Mat4f(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 10, 0, 1) + ] + tlas, _ = begin + ; + tlas_tmp = Raycore.TLAS(cl_backend); + push!(tlas_tmp, mesh, transforms); + sync!(tlas_tmp); + (tlas_tmp, [TLASHandle(UInt32(1))]); + end + + gpu_bound = tlas.root_aabb + cl_tlas = Adapt.adapt(cl_backend, tlas) + cl_bound = cl_tlas.root_aabb + + @test gpu_bound.p_min ≈ cl_bound.p_min + @test gpu_bound.p_max ≈ cl_bound.p_max + end - @test gpu_bound.p_min ≈ cl_bound.p_min - @test gpu_bound.p_max ≈ cl_bound.p_max end - -end end # if check_bounds From 2452f7b97d2e9431c8164f4afe17aed52013df82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Mon, 29 Jun 2026 00:59:27 +0200 Subject: [PATCH 07/12] Allow raw all_hits stacks --- src/instanced-bvh.jl | 13 ++++++++----- test/test_instanced_bvh.jl | 9 +++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/instanced-bvh.jl b/src/instanced-bvh.jl index 698fa94..e7f3752 100644 --- a/src/instanced-bvh.jl +++ b/src/instanced-bvh.jl @@ -2034,10 +2034,12 @@ end duplicate_epsilon::Float32, ) where {TMetadata} count_int = Int(count) - @inbounds for i in 1:count_int - out_idx = out_base + i - if metadata_out[out_idx] == metadata && abs(distances_out[out_idx] - distance) <= duplicate_epsilon - return count, false + if duplicate_epsilon >= 0.0f0 + @inbounds for i in 1:count_int + out_idx = out_base + i + if metadata_out[out_idx] == metadata && abs(distances_out[out_idx] - distance) <= duplicate_epsilon + return count, false + end end end @@ -2089,7 +2091,8 @@ retained and `overflow` is set. Hits with the same triangle metadata and a distance difference no larger than `duplicate_epsilon` are collapsed. This keeps coplanar duplicate triangles from -using extra stack slots while remaining GPU-kernel friendly. +using extra stack slots while remaining GPU-kernel friendly. Pass a negative +`duplicate_epsilon` to disable duplicate suppression and retain raw hits. """ @inline function all_hits!( metadata_out, diff --git a/test/test_instanced_bvh.jl b/test/test_instanced_bvh.jl index 9a70ae9..2dea8af 100644 --- a/test/test_instanced_bvh.jl +++ b/test/test_instanced_bvh.jl @@ -459,6 +459,15 @@ end @test overflow == false @test metadata[1] == UInt32(9) @test distances[1] ≈ 1.0f0 + + raw_metadata = fill(UInt32(0), 4) + raw_distances = fill(0.0f0, 4) + raw_count, raw_overflow = all_hits!(raw_metadata, raw_distances, tlas, ray, 0, 4, -1.0f0) + + @test raw_count == Int32(2) + @test raw_overflow == false + @test raw_metadata[1:2] == UInt32[9, 9] + @test raw_distances[1:2] ≈ Float32[1.0, 1.0] end @testset "TLAS all_hits! - Generic Metadata" begin From 70567de5df0031d371dba30d97c1b387f753f6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Mon, 29 Jun 2026 11:06:32 +0200 Subject: [PATCH 08/12] Add instance indices to `all_hits!` Extend `all_hits!` to accept and populate an `instance_indices_out` buffer with 1-based TLAS instance indices alongside hit metadata and distances. Duplicate suppression now also checks the instance index, so coplanar hits from different instances are correctly retained as separate hits. --- src/instanced-bvh.jl | 31 +++++++--- test/test_instanced_bvh.jl | 121 +++++++++++++++++++++++++++++++++---- 2 files changed, 132 insertions(+), 20 deletions(-) diff --git a/src/instanced-bvh.jl b/src/instanced-bvh.jl index e7f3752..3961473 100644 --- a/src/instanced-bvh.jl +++ b/src/instanced-bvh.jl @@ -2026,18 +2026,22 @@ end @inline function _insert_sorted_unique_hit!( metadata_out, distances_out, + instance_indices_out, out_base::Int, count::Int32, max_hits::Int, metadata::TMetadata, distance::Float32, + instance_index::UInt32, duplicate_epsilon::Float32, ) where {TMetadata} count_int = Int(count) if duplicate_epsilon >= 0.0f0 @inbounds for i in 1:count_int out_idx = out_base + i - if metadata_out[out_idx] == metadata && abs(distances_out[out_idx] - distance) <= duplicate_epsilon + if metadata_out[out_idx] == metadata && + instance_indices_out[out_idx] == instance_index && + abs(distances_out[out_idx] - distance) <= duplicate_epsilon return count, false end end @@ -2048,11 +2052,13 @@ end @inbounds while insert_pos > 1 && distances_out[out_base+insert_pos-1] > distance metadata_out[out_base+insert_pos] = metadata_out[out_base+insert_pos-1] distances_out[out_base+insert_pos] = distances_out[out_base+insert_pos-1] + instance_indices_out[out_base+insert_pos] = instance_indices_out[out_base+insert_pos-1] insert_pos -= 1 end @inbounds begin metadata_out[out_base+insert_pos] = metadata distances_out[out_base+insert_pos] = distance + instance_indices_out[out_base+insert_pos] = instance_index end return count + Int32(1), false end @@ -2069,34 +2075,41 @@ end @inbounds while insert_pos > 1 && distances_out[out_base+insert_pos-1] > distance metadata_out[out_base+insert_pos] = metadata_out[out_base+insert_pos-1] distances_out[out_base+insert_pos] = distances_out[out_base+insert_pos-1] + instance_indices_out[out_base+insert_pos] = instance_indices_out[out_base+insert_pos-1] insert_pos -= 1 end @inbounds begin metadata_out[out_base+insert_pos] = metadata distances_out[out_base+insert_pos] = distance + instance_indices_out[out_base+insert_pos] = instance_index end return count, true end """ - all_hits!(metadata_out, distances_out, tlas::StaticTLAS, ray, out_base, max_hits, duplicate_epsilon) + all_hits!(metadata_out, distances_out, instance_indices_out, tlas::StaticTLAS, ray, out_base, max_hits, duplicate_epsilon) -Traverse a `StaticTLAS` once and write sorted hit metadata and hit distances -into caller-provided buffers. +Traverse a `StaticTLAS` once and write sorted hit metadata, hit distances, and +1-based TLAS instance indices into caller-provided buffers. `out_base` is a zero-based offset into the output buffers, so hits are written to `out_base + 1:out_base + count`. The function returns `(count, overflow)`. When more than `max_hits` unique hits are found, the closest `max_hits` hits are retained and `overflow` is set. -Hits with the same triangle metadata and a distance difference no larger than -`duplicate_epsilon` are collapsed. This keeps coplanar duplicate triangles from -using extra stack slots while remaining GPU-kernel friendly. Pass a negative -`duplicate_epsilon` to disable duplicate suppression and retain raw hits. +`instance_indices_out[out_idx]` matches the `inst_idx` returned by +`closest_hit`: the 1-based position in `tlas.instances`. + +Hits with the same triangle metadata, same instance index, and a distance +difference no larger than `duplicate_epsilon` are collapsed. This keeps +coplanar duplicate triangles from using extra stack slots while remaining +GPU-kernel friendly. Pass a negative `duplicate_epsilon` to disable duplicate +suppression and retain raw hits. """ @inline function all_hits!( metadata_out, distances_out, + instance_indices_out, tlas::StaticTLAS, ray::R, out_base::Int, @@ -2170,11 +2183,13 @@ using extra stack slots while remaining GPU-kernel friendly. Pass a negative new_count, hit_overflow = _insert_sorted_unique_hit!( metadata_out, distances_out, + instance_indices_out, out_base, count, max_hits, tri.metadata, distance, + UInt32(current_instance + Int32(1)), duplicate_epsilon, ) count = new_count diff --git a/test/test_instanced_bvh.jl b/test/test_instanced_bvh.jl index 2dea8af..5c16dc8 100644 --- a/test/test_instanced_bvh.jl +++ b/test/test_instanced_bvh.jl @@ -16,12 +16,12 @@ const is_leaf = Raycore.is_leaf const is_interior = Raycore.is_interior # Kernel: all_hits! writes sorted hit stacks into caller-provided buffers -KernelAbstractions.@kernel function all_hits_kernel!(metadata_out, distances_out, counts_out, overflow_out, tlas, origins, directions, max_hits::Int, duplicate_epsilon::Float32) +KernelAbstractions.@kernel function all_hits_kernel!(metadata_out, distances_out, instance_indices_out, counts_out, overflow_out, tlas, origins, directions, max_hits::Int, duplicate_epsilon::Float32) i = @index(Global, Linear) @inbounds begin ray = Ray(o=origins[i], d=directions[i]) out_base = (i - 1) * max_hits - count, overflow = all_hits!(metadata_out, distances_out, tlas, ray, out_base, max_hits, duplicate_epsilon) + count, overflow = all_hits!(metadata_out, distances_out, instance_indices_out, tlas, ray, out_base, max_hits, duplicate_epsilon) counts_out[i] = count overflow_out[i] = overflow end @@ -417,22 +417,26 @@ end metadata = fill(UInt32(0), 4) distances = fill(0.0f0, 4) + instance_indices = fill(UInt32(0), 4) ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 4, 0.0f0) + count, overflow = all_hits!(metadata, distances, instance_indices, tlas, ray, 0, 4, 0.0f0) @test count == Int32(2) @test overflow == false @test metadata[1:2] == UInt32[7, 7] @test distances[1] ≈ 1.0f0 @test distances[2] ≈ 6.0f0 + @test instance_indices[1:2] == UInt32[1, 2] limited_metadata = fill(UInt32(0), 1) limited_distances = fill(0.0f0, 1) - limited_count, limited_overflow = all_hits!(limited_metadata, limited_distances, tlas, ray, 0, 1, 0.0f0) + limited_instance_indices = fill(UInt32(0), 1) + limited_count, limited_overflow = all_hits!(limited_metadata, limited_distances, limited_instance_indices, tlas, ray, 0, 1, 0.0f0) @test limited_count == Int32(1) @test limited_overflow == true @test limited_metadata[1] == UInt32(7) @test limited_distances[1] ≈ 1.0f0 + @test limited_instance_indices[1] == UInt32(1) end @testset "TLAS all_hits! - Duplicate Suppression" begin @@ -453,21 +457,63 @@ end ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) metadata = fill(UInt32(0), 4) distances = fill(0.0f0, 4) - count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 4, 1.0f-5) + instance_indices = fill(UInt32(0), 4) + count, overflow = all_hits!(metadata, distances, instance_indices, tlas, ray, 0, 4, 1.0f-5) @test count == Int32(1) @test overflow == false @test metadata[1] == UInt32(9) @test distances[1] ≈ 1.0f0 + @test instance_indices[1] == UInt32(1) raw_metadata = fill(UInt32(0), 4) raw_distances = fill(0.0f0, 4) - raw_count, raw_overflow = all_hits!(raw_metadata, raw_distances, tlas, ray, 0, 4, -1.0f0) + raw_instance_indices = fill(UInt32(0), 4) + raw_count, raw_overflow = all_hits!(raw_metadata, raw_distances, raw_instance_indices, tlas, ray, 0, 4, -1.0f0) @test raw_count == Int32(2) @test raw_overflow == false @test raw_metadata[1:2] == UInt32[9, 9] @test raw_distances[1:2] ≈ Float32[1.0, 1.0] + @test raw_instance_indices[1:2] == UInt32[1, 1] + end + + @testset "TLAS all_hits! - Duplicate Suppression Keeps Different Instances" begin + v1, v2, v3 = Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0) + tri = RTriangle( + SVector(v1, v2, v3), + SVector(Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)), + SVector(Vec3f(0), Vec3f(0), Vec3f(0)), + SVector(Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)), + UInt32(9) + ) + + blas = build_blas([tri]) + identity = Mat4f(I) + near_coplanar = Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -1.0f-6, 1 + ) + instances = [ + InstanceDescriptor(UInt32(1), UInt32(1), identity, identity, UInt32(0)), + InstanceDescriptor(UInt32(1), UInt32(2), near_coplanar, Mat4f(inv(near_coplanar)), UInt32(0)) + ] + tlas = build_tlas([blas], instances) + + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + metadata = fill(UInt32(0), 4) + distances = fill(0.0f0, 4) + instance_indices = fill(UInt32(0), 4) + count, overflow = all_hits!(metadata, distances, instance_indices, tlas, ray, 0, 4, 1.0f-5) + + @test count == Int32(2) + @test overflow == false + @test metadata[1:2] == UInt32[9, 9] + @test distances[1] ≈ 1.0f0 + @test distances[2] ≈ 1.000001f0 + @test instance_indices[1:2] == UInt32[1, 2] end @testset "TLAS all_hits! - Generic Metadata" begin @@ -500,14 +546,16 @@ end metadata = fill((surface=UInt32(0), medium=UInt32(0)), 2) distances = fill(0.0f0, 2) + instance_indices = fill(UInt32(0), 2) ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) - count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 2, 0.0f0) + count, overflow = all_hits!(metadata, distances, instance_indices, tlas, ray, 0, 2, 0.0f0) @test count == Int32(2) @test overflow == false @test metadata == [near_meta, far_meta] @test distances[1] ≈ 1.0f0 @test distances[2] ≈ 3.0f0 + @test instance_indices == UInt32[1, 1] end @testset "TLAS all_hits! - Overflow Keeps Closest Hits" begin @@ -545,13 +593,15 @@ end ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) metadata = fill(UInt32(0), 2) distances = fill(0.0f0, 2) - count, overflow = all_hits!(metadata, distances, tlas, ray, 0, 2, 0.0f0) + instance_indices = fill(UInt32(0), 2) + count, overflow = all_hits!(metadata, distances, instance_indices, tlas, ray, 0, 2, 0.0f0) @test count == Int32(2) @test overflow == true @test metadata == UInt32[11, 11] @test distances[1] ≈ 1.0f0 @test distances[2] ≈ 3.0f0 + @test instance_indices == UInt32[1, 2] end @testset "TLAS all_hits! - KernelAbstractions Kernel" begin @@ -590,11 +640,12 @@ end max_hits = 2 metadata = KernelAbstractions.allocate(backend, UInt32, n * max_hits) distances = KernelAbstractions.allocate(backend, Float32, n * max_hits) + instance_indices = KernelAbstractions.allocate(backend, UInt32, n * max_hits) counts = KernelAbstractions.allocate(backend, Int32, n) overflow = KernelAbstractions.allocate(backend, Bool, n) kernel = all_hits_kernel!(backend) - kernel(metadata, distances, counts, overflow, tlas, origins, directions, max_hits, 0.0f0; ndrange=n) + kernel(metadata, distances, instance_indices, counts, overflow, tlas, origins, directions, max_hits, 0.0f0; ndrange=n) KernelAbstractions.synchronize(backend) @test Array(counts) == Int32[2, 0] @@ -603,18 +654,21 @@ end @test distances_cpu[1] ≈ 1.0f0 @test distances_cpu[2] ≈ 6.0f0 @test Array(metadata)[1:2] == UInt32[13, 13] + @test Array(instance_indices)[1:2] == UInt32[1, 2] limited_metadata = KernelAbstractions.allocate(backend, UInt32, n) limited_distances = KernelAbstractions.allocate(backend, Float32, n) + limited_instance_indices = KernelAbstractions.allocate(backend, UInt32, n) limited_counts = KernelAbstractions.allocate(backend, Int32, n) limited_overflow = KernelAbstractions.allocate(backend, Bool, n) - kernel(limited_metadata, limited_distances, limited_counts, limited_overflow, tlas, origins, directions, 1, 0.0f0; ndrange=n) + kernel(limited_metadata, limited_distances, limited_instance_indices, limited_counts, limited_overflow, tlas, origins, directions, 1, 0.0f0; ndrange=n) KernelAbstractions.synchronize(backend) @test Array(limited_counts) == Int32[1, 0] @test Array(limited_overflow) == Bool[true, false] @test Array(limited_distances)[1] ≈ 1.0f0 @test Array(limited_metadata)[1] == UInt32(13) + @test Array(limited_instance_indices)[1] == UInt32(1) end @testset "TLAS any_hit - Basic" begin @@ -654,6 +708,44 @@ end GeometryBasics.mesh(verts, faces; normal=normals) end + @testset "TLAS all_hits! - Multi-Transform Prototype Mesh" begin + mesh = make_test_mesh( + [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], + [Normal3f(0, 0, 1), Normal3f(0, 0, 1), Normal3f(0, 0, 1)] + ) + transforms = [ + Mat4f(I), + Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -2, 1 + ), + Mat4f( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, -5, 1 + ), + ] + + tlas = Raycore.TLAS(KernelAbstractions.CPU()) + push!(tlas, mesh, transforms) + sync!(tlas) + + metadata = fill(UInt32(0), 3) + distances = fill(0.0f0, 3) + instance_indices = fill(UInt32(0), 3) + ray = Ray(o=Point3f(0.25, 0.25, 1.0), d=Vec3f(0, 0, -1)) + count, overflow = all_hits!(metadata, distances, instance_indices, tlas.static_tlas, ray, 0, 3, 0.0f0) + + @test count == Int32(3) + @test overflow == false + @test metadata[1:3] == UInt32[1, 1, 1] + @test distances[1:3] ≈ Float32[1.0, 3.0, 6.0] + @test instance_indices[1:3] == UInt32[1, 2, 3] + end + @testset "TLASHandle and n_instances" begin mesh1 = make_test_mesh( [Point3f(0, 0, 0), Point3f(1, 0, 0), Point3f(0, 1, 0)], @@ -1104,31 +1196,36 @@ else max_hits = 2 metadata = KA.allocate(cl_backend, UInt32, n * max_hits) distances = KA.allocate(cl_backend, Float32, n * max_hits) + instance_indices = KA.allocate(cl_backend, UInt32, n * max_hits) counts = KA.allocate(cl_backend, Int32, n) overflow = KA.allocate(cl_backend, Bool, n) kernel = all_hits_kernel!(cl_backend) - kernel(metadata, distances, counts, overflow, cl_tlas, origins, directions, max_hits, 0.0f0; ndrange=n) + kernel(metadata, distances, instance_indices, counts, overflow, cl_tlas, origins, directions, max_hits, 0.0f0; ndrange=n) KA.synchronize(cl_backend) counts_cpu = Array(counts) overflow_cpu = Array(overflow) distances_cpu = Array(distances) + instance_indices_cpu = Array(instance_indices) @test counts_cpu == Int32[2, 0] @test overflow_cpu == Bool[false, false] @test distances_cpu[1] ≈ 1.0f0 @test distances_cpu[2] ≈ 6.0f0 + @test instance_indices_cpu[1:2] == UInt32[1, 2] limited_metadata = KA.allocate(cl_backend, UInt32, n) limited_distances = KA.allocate(cl_backend, Float32, n) + limited_instance_indices = KA.allocate(cl_backend, UInt32, n) limited_counts = KA.allocate(cl_backend, Int32, n) limited_overflow = KA.allocate(cl_backend, Bool, n) - kernel(limited_metadata, limited_distances, limited_counts, limited_overflow, cl_tlas, origins, directions, 1, 0.0f0; ndrange=n) + kernel(limited_metadata, limited_distances, limited_instance_indices, limited_counts, limited_overflow, cl_tlas, origins, directions, 1, 0.0f0; ndrange=n) KA.synchronize(cl_backend) @test Array(limited_counts) == Int32[1, 0] @test Array(limited_overflow) == Bool[true, false] @test Array(limited_distances)[1] ≈ 1.0f0 + @test Array(limited_instance_indices)[1] == UInt32(1) end @testset "any_hit_kernel! - shadow/occlusion test" begin From c761a29fccd483ae4df84ea226825eb84e4ca47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Mon, 29 Jun 2026 11:27:10 +0200 Subject: [PATCH 09/12] Update instanced-bvh.jl rename variables consistantly with other functions (e.g. closest_hit) --- src/instanced-bvh.jl | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/instanced-bvh.jl b/src/instanced-bvh.jl index 3961473..5ca60bf 100644 --- a/src/instanced-bvh.jl +++ b/src/instanced-bvh.jl @@ -2031,8 +2031,8 @@ end count::Int32, max_hits::Int, metadata::TMetadata, - distance::Float32, - instance_index::UInt32, + t::Float32, + inst_idx::UInt32, duplicate_epsilon::Float32, ) where {TMetadata} count_int = Int(count) @@ -2040,8 +2040,8 @@ end @inbounds for i in 1:count_int out_idx = out_base + i if metadata_out[out_idx] == metadata && - instance_indices_out[out_idx] == instance_index && - abs(distances_out[out_idx] - distance) <= duplicate_epsilon + instance_indices_out[out_idx] == inst_idx && + abs(distances_out[out_idx] - t) <= duplicate_epsilon return count, false end end @@ -2049,7 +2049,7 @@ end if count_int < max_hits insert_pos = count_int + 1 - @inbounds while insert_pos > 1 && distances_out[out_base+insert_pos-1] > distance + @inbounds while insert_pos > 1 && distances_out[out_base+insert_pos-1] > t metadata_out[out_base+insert_pos] = metadata_out[out_base+insert_pos-1] distances_out[out_base+insert_pos] = distances_out[out_base+insert_pos-1] instance_indices_out[out_base+insert_pos] = instance_indices_out[out_base+insert_pos-1] @@ -2057,8 +2057,8 @@ end end @inbounds begin metadata_out[out_base+insert_pos] = metadata - distances_out[out_base+insert_pos] = distance - instance_indices_out[out_base+insert_pos] = instance_index + distances_out[out_base+insert_pos] = t + instance_indices_out[out_base+insert_pos] = inst_idx end return count + Int32(1), false end @@ -2067,12 +2067,12 @@ end return count, true end - @inbounds if distance >= distances_out[out_base+max_hits] + @inbounds if t >= distances_out[out_base+max_hits] return count, true end insert_pos = max_hits - @inbounds while insert_pos > 1 && distances_out[out_base+insert_pos-1] > distance + @inbounds while insert_pos > 1 && distances_out[out_base+insert_pos-1] > t metadata_out[out_base+insert_pos] = metadata_out[out_base+insert_pos-1] distances_out[out_base+insert_pos] = distances_out[out_base+insert_pos-1] instance_indices_out[out_base+insert_pos] = instance_indices_out[out_base+insert_pos-1] @@ -2080,8 +2080,8 @@ end end @inbounds begin metadata_out[out_base+insert_pos] = metadata - distances_out[out_base+insert_pos] = distance - instance_indices_out[out_base+insert_pos] = instance_index + distances_out[out_base+insert_pos] = t + instance_indices_out[out_base+insert_pos] = inst_idx end return count, true end @@ -2177,9 +2177,10 @@ suppression and retain raw hits. ray_inv_d = safe_invdir(ray_d) continue else - hit, distance, _u, _v = intersect_leaf_node(node, ray_d, ray_o, ray_mint, ray_maxt) + hit, t, _u, _v = intersect_leaf_node(node, ray_d, ray_o, ray_mint, ray_maxt) if hit tri = tlas_blas_prims[current_prim_offset + node.child1] + inst_idx = UInt32(current_instance + Int32(1)) new_count, hit_overflow = _insert_sorted_unique_hit!( metadata_out, distances_out, @@ -2188,8 +2189,8 @@ suppression and retain raw hits. count, max_hits, tri.metadata, - distance, - UInt32(current_instance + Int32(1)), + t, + inst_idx, duplicate_epsilon, ) count = new_count From 4a2b3be50e76fcfd1547c6b3730b476d24e22623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 30 Jun 2026 14:33:15 +0200 Subject: [PATCH 10/12] Increase traversal stack capacity --- src/instanced-bvh.jl | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/instanced-bvh.jl b/src/instanced-bvh.jl index 5ca60bf..d0555ee 100644 --- a/src/instanced-bvh.jl +++ b/src/instanced-bvh.jl @@ -63,6 +63,7 @@ struct BVHNode2 end const INVALID_NODE = 0xffffffff +const SOFTWARE_TRAVERSAL_STACK_CAPACITY = 64 """Check if a node is a leaf node.""" @inline is_leaf(node::BVHNode2) = node.child0 == INVALID_NODE @@ -1908,8 +1909,9 @@ Algorithm: ray_maxt::Float32 = ray.t_max ray_inv_d::Vec3f = safe_invdir(ray_d) # Use safe inversion to avoid division by zero - # Stack for traversal (32 entries sufficient for typical BVH depths of ~20 levels) - stack = MVector{32, UInt32}(undef) + # Stack for traversal. This is intentionally larger than the common ~20 + # level case because realistic flattened BLASes can exceed 32 levels. + stack = MVector{SOFTWARE_TRAVERSAL_STACK_CAPACITY,UInt32}(undef) stack_ptr::Int32 = Int32(1) @inbounds stack[stack_ptr] = INVALID_NODE @@ -2123,7 +2125,7 @@ suppression and retain raw hits. ray_maxt::Float32 = ray.t_max ray_inv_d::Vec3f = safe_invdir(ray_d) - stack = MVector{32, UInt32}(undef) + stack = MVector{SOFTWARE_TRAVERSAL_STACK_CAPACITY,UInt32}(undef) stack_ptr::Int32 = Int32(1) @inbounds stack[stack_ptr] = INVALID_NODE @@ -2232,8 +2234,9 @@ Matches HLSL TraceRays with ANY_HIT defined. ray_maxt::Float32 = ray.t_max ray_inv_d::Vec3f = safe_invdir(ray_d) - # Stack for traversal (32 entries sufficient for typical BVH depths of ~20 levels) - stack = MVector{32, UInt32}(undef) + # Stack for traversal. This matches the TLAS closest/all-hit traversal + # capacity so the three software paths fail or pass on the same topology. + stack = MVector{SOFTWARE_TRAVERSAL_STACK_CAPACITY,UInt32}(undef) stack_ptr::Int32 = Int32(1) @inbounds stack[stack_ptr] = INVALID_NODE From d886123fea62ce1814021ab8a627b089885648f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 30 Jun 2026 16:49:10 +0200 Subject: [PATCH 11/12] only change the stack size for `all_hits!` --- src/instanced-bvh.jl | 174 +++++++++++++++++++++---------------------- 1 file changed, 87 insertions(+), 87 deletions(-) diff --git a/src/instanced-bvh.jl b/src/instanced-bvh.jl index d0555ee..9528e0d 100644 --- a/src/instanced-bvh.jl +++ b/src/instanced-bvh.jl @@ -28,7 +28,7 @@ import AcceleratedKernels as AK # Vulkan-compatible 3×4 transform (row-major). SMatrix{4,3} is column-major and # byte-identical to a Vulkan row-major 3×4, so the two conventions share the # same memory layout without any reinterpret. -const Mat3x4f = SMatrix{4, 3, Float32, 12} +const Mat3x4f = SMatrix{4,3,Float32,12} # ============================================================================== # Core Data Structures @@ -63,7 +63,6 @@ struct BVHNode2 end const INVALID_NODE = 0xffffffff -const SOFTWARE_TRAVERSAL_STACK_CAPACITY = 64 """Check if a node is a leaf node.""" @inline is_leaf(node::BVHNode2) = node.child0 == INVALID_NODE @@ -110,8 +109,8 @@ Bottom-Level Acceleration Structure - BVH over triangle geometry. Type parameters allow CPU (Vector) or GPU (CuArray, ROCArray) storage. """ struct BLAS{ - NodeArray <: AbstractVector{BVHNode2}, - TriArray <: AbstractVector{<:Triangle} + NodeArray<:AbstractVector{BVHNode2}, + TriArray<:AbstractVector{<:Triangle} } nodes::NodeArray primitives::TriArray @@ -154,11 +153,11 @@ The struct is immutable and contains only the arrays needed for ray traversal. No management state (dictionaries, free lists, etc.) - those stay on CPU in TLAS. """ struct StaticTLAS{ - NodeArray <: AbstractVector{BVHNode2}, - InstArray <: AbstractVector{InstanceDescriptor}, - BLASNodeArray <: AbstractVector{BVHNode2}, - BLASPrimArray <: AbstractVector{<:Triangle}, - DescArray <: AbstractVector{BLASDescriptor} + NodeArray<:AbstractVector{BVHNode2}, + InstArray<:AbstractVector{InstanceDescriptor}, + BLASNodeArray<:AbstractVector{BVHNode2}, + BLASPrimArray<:AbstractVector{<:Triangle}, + DescArray<:AbstractVector{BLASDescriptor} } <: AbstractAdaptedAccel nodes::NodeArray instances::InstArray @@ -274,12 +273,12 @@ mutable struct TLAS{Backend} <: AbstractAccel # `blas_array` is a backend array of isbits `BLAS{NodeArr,TriArr}`. Element # type varies by mesh metadata, so no tighter bound; `nothing` until the # first `push!` because the concrete BLAS type isn't known at construction. - blas_array::Union{Nothing, AbstractVector} + blas_array::Union{Nothing,AbstractVector} root_aabb::Bounds3 # CPU-side management (dictionaries must stay on CPU for O(1) lookup) - handle_to_range::Dict{TLASHandle, UnitRange{Int}} + handle_to_range::Dict{TLASHandle,UnitRange{Int}} deleted_handles::Set{TLASHandle} # Per-BLAS backing arrays. Structural composition: the TLAS owning @@ -290,15 +289,15 @@ mutable struct TLAS{Backend} <: AbstractAccel # Flat BLAS arrays for StaticTLAS traversal (built during sync!, kept alive for isbits pointers). # `nothing` before the first `build_flat_blas_arrays!`. - _flat_blas_nodes::Union{Nothing, AbstractVector{BVHNode2}} - _flat_blas_prims::Union{Nothing, AbstractVector} # see blas_array note - _flat_blas_descs::Union{Nothing, AbstractVector{BLASDescriptor}} + _flat_blas_nodes::Union{Nothing,AbstractVector{BVHNode2}} + _flat_blas_prims::Union{Nothing,AbstractVector} # see blas_array note + _flat_blas_descs::Union{Nothing,AbstractVector{BLASDescriptor}} # GPU-adapted form, owned by sync!. Consumers read this via `tlas.static_tlas` # or `Adapt.adapt(backend, tlas)` per dispatch — do NOT cache across # mutations. See the TLAS docstring for the full invariant. # `nothing` until the first sync!. - static_tlas::Union{Nothing, StaticTLAS} + static_tlas::Union{Nothing,StaticTLAS} # Whether BVH topology needs rebuild (geometry added/removed) dirty::Bool @@ -340,7 +339,7 @@ function TLAS(backend) KA.allocate(backend, InstanceDescriptor, 0), # instances (direct GPU append) allocate_empty_blas_array(backend), # blas_array (GPU array of isbits BLASes) Bounds3(), # root_aabb - Dict{TLASHandle, UnitRange{Int}}(), # handle_to_range + Dict{TLASHandle,UnitRange{Int}}(), # handle_to_range Set{TLASHandle}(), # deleted_handles BLASArrays[], # blas_storage nothing, # _flat_blas_nodes @@ -555,11 +554,11 @@ Build a Triangle from decomposed mesh arrays at the given face index. """ function build_triangle(vertices, normals, uvs, indices, face_idx, metadata) f_idx = 1 + (3 * (face_idx - 1)) - vs = @SVector [vertices[indices[f_idx + i]] for i in 0:2] - ns = @SVector [normals[indices[f_idx + i]] for i in 0:2] + vs = @SVector [vertices[indices[f_idx+i]] for i in 0:2] + ns = @SVector [normals[indices[f_idx+i]] for i in 0:2] ts = @SVector [Vec3f(NaN) for _ in 1:3] uv = if !isempty(uvs) - @SVector [uvs[indices[f_idx + i]] for i in 0:2] + @SVector [uvs[indices[f_idx+i]] for i in 0:2] else SVector(Point2f(0), Point2f(1, 0), Point2f(1, 1)) end @@ -573,7 +572,7 @@ Check if a triangle face is degenerate (zero area) without constructing a full T """ function is_degenerate_face(vertices, indices, face_idx) f_idx = 1 + (3 * (face_idx - 1)) - vs = @SVector [vertices[indices[f_idx + i]] for i in 0:2] + vs = @SVector [vertices[indices[f_idx+i]] for i in 0:2] is_degenerate(vs) end @@ -591,7 +590,8 @@ function build_and_append_blas!(tlas::TLAS, mesh::GeometryBasics.Mesh) has_meta = hasproperty(nmesh, :face_meta) n_faces = length(fs) - cpu_triangles = [begin + cpu_triangles = [ + begin # After expand_faceviews, face_meta is per-vertex with all 3 verts sharing same value meta = has_meta ? nmesh.face_meta[indices[3*(i-1)+1]] : UInt32(i) build_triangle(verts, norms, uvs, indices, i, meta) @@ -638,8 +638,8 @@ per-triangle interface — see `InstanceDescriptor` for semantics. Returns a stable handle for later reference. """ function Base.push!(tlas::TLAS, mesh::GeometryBasics.Mesh, transform::Mat4f=Mat4f(I); - instance_id::UInt32=UInt32(0), - sbt_offset::UInt32=UInt32(0)) # ignored on SW; matches HWTLAS kwarg + instance_id::UInt32=UInt32(0), + sbt_offset::UInt32=UInt32(0)) # ignored on SW; matches HWTLAS kwarg blas_idx = build_and_append_blas!(tlas, mesh) t = mat4_to_mat3x4(transform) cpu_descriptors = [InstanceDescriptor(blas_idx, instance_id, t, mat3x4_inverse(t), UInt32(0))] @@ -660,8 +660,8 @@ per-instance interface override. When `nothing`, every instance gets `0` Returns a stable handle for later reference. """ function Base.push!(tlas::TLAS, mesh::GeometryBasics.Mesh, transforms::AbstractVector{Mat4f}; - instance_ids::Union{Nothing, AbstractVector{<:Integer}}=nothing, - sbt_offset::UInt32=UInt32(0)) # ignored on SW; matches HWTLAS kwarg + instance_ids::Union{Nothing,AbstractVector{<:Integer}}=nothing, + sbt_offset::UInt32=UInt32(0)) # ignored on SW; matches HWTLAS kwarg if instance_ids !== nothing && length(instance_ids) != length(transforms) throw(ArgumentError("instance_ids length $(length(instance_ids)) != transforms length $(length(transforms))")) end @@ -828,7 +828,8 @@ function update!(tlas::TLAS, handle::TLASHandle, new_geometry) has_meta = hasproperty(nmesh, :face_meta) n_faces = length(fs) - cpu_triangles = [begin + cpu_triangles = [ + begin meta = has_meta ? nmesh.face_meta[indices[3*(i-1)+1]] : UInt32(i) build_triangle(verts, norms, uvs, indices, i, meta) end @@ -998,7 +999,7 @@ function compact_instances!(tlas::TLAS) # Copy valid ranges to new array and update handle mappings cpu_instances = Array(tlas.instances) new_instances = InstanceDescriptor[] - new_handle_to_range = Dict{TLASHandle, UnitRange{Int}}() + new_handle_to_range = Dict{TLASHandle,UnitRange{Int}}() for (handle, range) in tlas.handle_to_range handle in tlas.deleted_handles && continue @@ -1025,7 +1026,7 @@ function compact_instances!(tlas::TLAS) if n_blas > 0 && length(used_blas_indices) < n_blas # Build old→new index mapping (only for referenced BLASes) sorted_used = sort!(collect(used_blas_indices)) - old_to_new = Dict{UInt32, UInt32}() + old_to_new = Dict{UInt32,UInt32}() for (new_idx, old_idx) in enumerate(sorted_used) old_to_new[old_idx] = UInt32(new_idx) end @@ -1061,8 +1062,8 @@ function compact_instances!(tlas::TLAS) # Update instances on backend tlas.instances = isempty(new_instances) ? - KA.allocate(tlas.backend, InstanceDescriptor, 0) : - Adapt.adapt(tlas.backend, new_instances) + KA.allocate(tlas.backend, InstanceDescriptor, 0) : + Adapt.adapt(tlas.backend, new_instances) end # ------------------------------------------------------------------------------ @@ -1197,7 +1198,7 @@ Interleaves x,y,z bits to create space-filling Z-curve ordering. # Interleave bits: xxyyzzxxyyzzxxyyzz... return (expand_bits(unsafe_trunc(UInt32, x)) << 2) | (expand_bits(unsafe_trunc(UInt32, y)) << 1) | - expand_bits(unsafe_trunc(UInt32, z)) + expand_bits(unsafe_trunc(UInt32, z)) end """Count leading zeros (clz) for 32-bit integer.""" @@ -1234,7 +1235,7 @@ end idx::Int32, morton_codes::AbstractVector{UInt32}, n_prims::Int32 -)::Tuple{Int32, Int32} +)::Tuple{Int32,Int32} # Determine direction d_left = delta(idx, idx - Int32(1), morton_codes, n_prims) d_right = delta(idx, idx + Int32(1), morton_codes, n_prims) @@ -1376,7 +1377,7 @@ blas_gpu = build_blas(gpu_triangles) # CuArray{Triangle} """ function build_blas( primitives::AbstractVector{T} -) where {T <: Triangle} +) where {T<:Triangle} n = length(primitives) n == 0 && error("Cannot build BLAS from empty primitive list") @@ -1504,11 +1505,11 @@ function build_tlas_topology(blas_array, instances, backend) scene_max_p = cpu_maxs[1] for i in 2:n scene_min_p = Point3f(min(scene_min_p[1], cpu_mins[i][1]), - min(scene_min_p[2], cpu_mins[i][2]), - min(scene_min_p[3], cpu_mins[i][3])) + min(scene_min_p[2], cpu_mins[i][2]), + min(scene_min_p[3], cpu_mins[i][3])) scene_max_p = Point3f(max(scene_max_p[1], cpu_maxs[i][1]), - max(scene_max_p[2], cpu_maxs[i][2]), - max(scene_max_p[3], cpu_maxs[i][3])) + max(scene_max_p[2], cpu_maxs[i][2]), + max(scene_max_p[3], cpu_maxs[i][3])) end scene_aabb = Bounds3(scene_min_p, scene_max_p) @@ -1606,7 +1607,7 @@ Uses KernelAbstractions for automatic CPU/GPU execution based on input array typ function build_tlas( blas_array::AbstractVector{B}, instances::AbstractVector{InstanceDescriptor} -) where {B <: BLAS} +) where {B<:BLAS} n_blas = length(blas_array) n = length(instances) @@ -1653,7 +1654,7 @@ end # Type union for traversal - both TLAS and StaticTLAS have the same traversal-relevant fields -const TraversableTLAS = Union{TLAS, StaticTLAS} +const TraversableTLAS = Union{TLAS,StaticTLAS} # ============================================================================== # Transform Utilities @@ -1663,9 +1664,9 @@ const TraversableTLAS = Union{TLAS, StaticTLAS} # The upper three rows of the 4×4 become the three Vulkan rows. @inline function mat4_to_mat3x4(m)::Mat3x4f Mat3x4f( - Float32(m[1,1]), Float32(m[1,2]), Float32(m[1,3]), Float32(m[1,4]), - Float32(m[2,1]), Float32(m[2,2]), Float32(m[2,3]), Float32(m[2,4]), - Float32(m[3,1]), Float32(m[3,2]), Float32(m[3,3]), Float32(m[3,4]), + Float32(m[1, 1]), Float32(m[1, 2]), Float32(m[1, 3]), Float32(m[1, 4]), + Float32(m[2, 1]), Float32(m[2, 2]), Float32(m[2, 3]), Float32(m[2, 4]), + Float32(m[3, 1]), Float32(m[3, 2]), Float32(m[3, 3]), Float32(m[3, 4]), ) end @@ -1676,14 +1677,14 @@ end @inline function mat3x4_inverse(m::Mat3x4f)::Mat3x4f R = m[SOneTo(3), SOneTo(3)] B = inv(R) - tx, ty, tz = m[4,1], m[4,2], m[4,3] - t_inv_x = -(B[1,1]*tx + B[2,1]*ty + B[3,1]*tz) - t_inv_y = -(B[1,2]*tx + B[2,2]*ty + B[3,2]*tz) - t_inv_z = -(B[1,3]*tx + B[2,3]*ty + B[3,3]*tz) + tx, ty, tz = m[4, 1], m[4, 2], m[4, 3] + t_inv_x = -(B[1, 1]*tx + B[2, 1]*ty + B[3, 1]*tz) + t_inv_y = -(B[1, 2]*tx + B[2, 2]*ty + B[3, 2]*tz) + t_inv_z = -(B[1, 3]*tx + B[2, 3]*ty + B[3, 3]*tz) return Mat3x4f( - B[1,1], B[2,1], B[3,1], t_inv_x, - B[1,2], B[2,2], B[3,2], t_inv_y, - B[1,3], B[2,3], B[3,3], t_inv_z, + B[1, 1], B[2, 1], B[3, 1], t_inv_x, + B[1, 2], B[2, 2], B[3, 2], t_inv_y, + B[1, 3], B[2, 3], B[3, 3], t_inv_z, ) end @@ -1692,9 +1693,9 @@ end # a dot of one Julia column with (p..., 1). @inline function transform_point(m::Mat3x4f, p::Point3f)::Point3f Point3f( - m[1,1] * p[1] + m[2,1] * p[2] + m[3,1] * p[3] + m[4,1], - m[1,2] * p[1] + m[2,2] * p[2] + m[3,2] * p[3] + m[4,2], - m[1,3] * p[1] + m[2,3] * p[2] + m[3,3] * p[3] + m[4,3], + m[1, 1] * p[1] + m[2, 1] * p[2] + m[3, 1] * p[3] + m[4, 1], + m[1, 2] * p[1] + m[2, 2] * p[2] + m[3, 2] * p[3] + m[4, 2], + m[1, 3] * p[1] + m[2, 3] * p[2] + m[3, 3] * p[3] + m[4, 3], ) end @@ -1702,27 +1703,27 @@ end # Standard graphics convention: out_i = sum_j m[i,j]*p[j] + m[i,4]. @inline function transform_point(m::Mat4f, p::Point3f)::Point3f Point3f( - m[1,1] * p[1] + m[1,2] * p[2] + m[1,3] * p[3] + m[1,4], - m[2,1] * p[1] + m[2,2] * p[2] + m[2,3] * p[3] + m[2,4], - m[3,1] * p[1] + m[3,2] * p[2] + m[3,3] * p[3] + m[3,4], + m[1, 1] * p[1] + m[1, 2] * p[2] + m[1, 3] * p[3] + m[1, 4], + m[2, 1] * p[1] + m[2, 2] * p[2] + m[2, 3] * p[3] + m[2, 4], + m[3, 1] * p[1] + m[3, 2] * p[2] + m[3, 3] * p[3] + m[3, 4], ) end # Transform direction (ignoring translation). @inline function transform_direction(m::Mat3x4f, v::Vec3f)::Vec3f Vec3f( - m[1,1] * v[1] + m[2,1] * v[2] + m[3,1] * v[3], - m[1,2] * v[1] + m[2,2] * v[2] + m[3,2] * v[3], - m[1,3] * v[1] + m[2,3] * v[2] + m[3,3] * v[3], + m[1, 1] * v[1] + m[2, 1] * v[2] + m[3, 1] * v[3], + m[1, 2] * v[1] + m[2, 2] * v[2] + m[3, 2] * v[3], + m[1, 3] * v[1] + m[2, 3] * v[2] + m[3, 3] * v[3], ) end # Mat4f variant — translation column ignored for direction transforms. @inline function transform_direction(m::Mat4f, v::Vec3f)::Vec3f Vec3f( - m[1,1] * v[1] + m[1,2] * v[2] + m[1,3] * v[3], - m[2,1] * v[1] + m[2,2] * v[2] + m[2,3] * v[3], - m[3,1] * v[1] + m[3,2] * v[2] + m[3,3] * v[3], + m[1, 1] * v[1] + m[1, 2] * v[2] + m[1, 3] * v[3], + m[2, 1] * v[1] + m[2, 2] * v[2] + m[2, 3] * v[3], + m[3, 1] * v[1] + m[3, 2] * v[2] + m[3, 3] * v[3], ) end @@ -1900,7 +1901,7 @@ Algorithm: 4. Transform back to world space 5. Return closest hit across all instances """ -@inline function closest_hit(tlas::StaticTLAS, ray::R) where {R <: AbstractRay} +@inline function closest_hit(tlas::StaticTLAS, ray::R) where {R<:AbstractRay} # Initialize traversal state - matches HLSL TraceRays ray = check_direction(ray) ray_o::Point3f = ray.o @@ -1909,9 +1910,8 @@ Algorithm: ray_maxt::Float32 = ray.t_max ray_inv_d::Vec3f = safe_invdir(ray_d) # Use safe inversion to avoid division by zero - # Stack for traversal. This is intentionally larger than the common ~20 - # level case because realistic flattened BLASes can exceed 32 levels. - stack = MVector{SOFTWARE_TRAVERSAL_STACK_CAPACITY,UInt32}(undef) + # Stack for traversal (32 entries sufficient for typical BVH depths of ~20 levels) + stack = MVector{32,UInt32}(undef) stack_ptr::Int32 = Int32(1) @inbounds stack[stack_ptr] = INVALID_NODE @@ -1940,7 +1940,7 @@ Algorithm: node::BVHNode2 = if current_instance < Int32(0) tlas_nodes[node_index] else - tlas_blas_nodes[current_blas_offset + node_index] + tlas_blas_nodes[current_blas_offset+node_index] end is_leaf::Bool = (node.child0 == INVALID_NODE) @@ -1970,7 +1970,7 @@ Algorithm: # Get instance and transform ray node_index = UInt32(1) # Start at root of BLAS - inst = tlas_instances[current_instance + Int32(1)] + inst = tlas_instances[current_instance+Int32(1)] desc = tlas_blas_descs[inst.blas_index] current_blas_offset = desc.nodes_offset ray_o = transform_point(inst.inv_transform, ray.o) @@ -2013,14 +2013,14 @@ Algorithm: inst_idx = UInt32(closest_instance + Int32(1)) inst = tlas_instances[inst_idx] desc = tlas_blas_descs[inst.blas_index] - tri = tlas_blas_prims[desc.primitives_offset + closest_prim] + tri = tlas_blas_prims[desc.primitives_offset+closest_prim] w = 1.0f0 - hit_u - hit_v - bary = SVector{3, Float32}(w, hit_u, hit_v) + bary = SVector{3,Float32}(w, hit_u, hit_v) return (true, tri, ray_maxt, bary, inst_idx) else # No hit - return zero sentinel dummy_tri = empty_triangle(eltype(tlas_blas_prims)) - bary = SVector{3, Float32}(0.0f0, 0.0f0, 0.0f0) + bary = SVector{3,Float32}(0.0f0, 0.0f0, 0.0f0) return (false, dummy_tri, 0.0f0, bary, UInt32(0)) end end @@ -2117,7 +2117,7 @@ suppression and retain raw hits. out_base::Int, max_hits::Int, duplicate_epsilon::Float32, -) where {R <: AbstractRay} +) where {R<:AbstractRay} ray = check_direction(ray) ray_o::Point3f = ray.o ray_d::Vec3f = ray.d @@ -2125,7 +2125,8 @@ suppression and retain raw hits. ray_maxt::Float32 = ray.t_max ray_inv_d::Vec3f = safe_invdir(ray_d) - stack = MVector{SOFTWARE_TRAVERSAL_STACK_CAPACITY,UInt32}(undef) + stack = MVector{64,UInt32}(undef) + # Note that the stack size (64) is larger than the other algos (32) because this algorithm can exceed 32 levels. stack_ptr::Int32 = Int32(1) @inbounds stack[stack_ptr] = INVALID_NODE @@ -2146,7 +2147,7 @@ suppression and retain raw hits. node::BVHNode2 = if current_instance < Int32(0) tlas_nodes[node_index] else - tlas_blas_nodes[current_blas_offset + node_index] + tlas_blas_nodes[current_blas_offset+node_index] end is_leaf::Bool = (node.child0 == INVALID_NODE) @@ -2170,7 +2171,7 @@ suppression and retain raw hits. stack[stack_ptr] = TOP_LEVEL_SENTINEL node_index = UInt32(1) - inst = tlas_instances[current_instance + Int32(1)] + inst = tlas_instances[current_instance+Int32(1)] desc = tlas_blas_descs[inst.blas_index] current_blas_offset = desc.nodes_offset current_prim_offset = desc.primitives_offset @@ -2181,7 +2182,7 @@ suppression and retain raw hits. else hit, t, _u, _v = intersect_leaf_node(node, ray_d, ray_o, ray_mint, ray_maxt) if hit - tri = tlas_blas_prims[current_prim_offset + node.child1] + tri = tlas_blas_prims[current_prim_offset+node.child1] inst_idx = UInt32(current_instance + Int32(1)) new_count, hit_overflow = _insert_sorted_unique_hit!( metadata_out, @@ -2225,7 +2226,7 @@ Faster than closest_hit when only occlusion testing is needed. Matches HLSL TraceRays with ANY_HIT defined. """ -@inline function any_hit(tlas::StaticTLAS, ray::R) where {R <: AbstractRay} +@inline function any_hit(tlas::StaticTLAS, ray::R) where {R<:AbstractRay} # Initialize traversal state - matches HLSL TraceRays ray = check_direction(ray) ray_o::Point3f = ray.o @@ -2234,9 +2235,8 @@ Matches HLSL TraceRays with ANY_HIT defined. ray_maxt::Float32 = ray.t_max ray_inv_d::Vec3f = safe_invdir(ray_d) - # Stack for traversal. This matches the TLAS closest/all-hit traversal - # capacity so the three software paths fail or pass on the same topology. - stack = MVector{SOFTWARE_TRAVERSAL_STACK_CAPACITY,UInt32}(undef) + # Stack for traversal (32 entries sufficient for typical BVH depths of ~20 levels) + stack = MVector{32,UInt32}(undef) stack_ptr::Int32 = Int32(1) @inbounds stack[stack_ptr] = INVALID_NODE @@ -2258,7 +2258,7 @@ Matches HLSL TraceRays with ANY_HIT defined. node::BVHNode2 = if current_instance < Int32(0) tlas_nodes[node_index] else - tlas_blas_nodes[current_blas_offset + node_index] + tlas_blas_nodes[current_blas_offset+node_index] end is_leaf::Bool = (node.child0 == INVALID_NODE) @@ -2288,7 +2288,7 @@ Matches HLSL TraceRays with ANY_HIT defined. # Get instance and transform ray node_index = UInt32(1) # Start at root of BLAS - inst = tlas_instances[current_instance + Int32(1)] + inst = tlas_instances[current_instance+Int32(1)] desc = tlas_blas_descs[inst.blas_index] current_blas_offset = desc.nodes_offset ray_o = transform_point(inst.inv_transform, ray.o) @@ -2303,9 +2303,9 @@ Matches HLSL TraceRays with ANY_HIT defined. inst_idx = UInt32(current_instance + Int32(1)) inst = tlas_instances[inst_idx] desc = tlas_blas_descs[inst.blas_index] - tri = tlas_blas_prims[desc.primitives_offset + node.child1] + tri = tlas_blas_prims[desc.primitives_offset+node.child1] w = 1.0f0 - u - v - bary = SVector{3, Float32}(w, u, v) + bary = SVector{3,Float32}(w, u, v) return (true, tri, t, bary, inst_idx) end end @@ -2330,7 +2330,7 @@ Matches HLSL TraceRays with ANY_HIT defined. # No hit found @inbounds dummy_tri = tlas_blas_prims[1] - bary = SVector{3, Float32}(0.0f0, 0.0f0, 0.0f0) + bary = SVector{3,Float32}(0.0f0, 0.0f0, 0.0f0) return (false, dummy_tri, 0.0f0, bary, UInt32(0)) end @@ -2392,7 +2392,7 @@ Operates directly on the backend arrays stored in the TLAS. function refit_tlas!(tlas::TLAS) tlas.transforms_dirty || return tlas n = length(tlas.instances) - n == 0 && (tlas.transforms_dirty = false; return tlas) + n == 0 && (tlas.transforms_dirty=false; return tlas) backend = tlas.backend # Update leaf node AABBs from new transforms (kernel) @@ -2471,7 +2471,7 @@ tlas = TLAS(geometries, metadata_fn; backend=OpenCLBackend()) function TLAS( primitives::AbstractVector{P}, metadata_fn::Function; - backend = KA.CPU() + backend=KA.CPU() ) where {P} first_metadata = metadata_fn(1, 1) TMetadata = typeof(first_metadata) @@ -2532,7 +2532,7 @@ function Base.eltype(tlas::TLAS) return eltype(tlas.blas_storage[1].primitives) end -function Base.eltype(::StaticTLAS{NA, IA, BNA, BPA, DA}) where {NA, IA, BNA, BPA, DA} +function Base.eltype(::StaticTLAS{NA,IA,BNA,BPA,DA}) where {NA,IA,BNA,BPA,DA} return eltype(BPA) end From 4dd18ddfb60305e839f518ac408b5ed35761b91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Wed, 1 Jul 2026 15:27:39 +0200 Subject: [PATCH 12/12] Use AK's mapreduce for the computing the scene's AABB This allows to give a neutral value for GPU padding/workgroup lanes --- src/instanced-bvh.jl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/instanced-bvh.jl b/src/instanced-bvh.jl index 9528e0d..0641100 100644 --- a/src/instanced-bvh.jl +++ b/src/instanced-bvh.jl @@ -1384,8 +1384,10 @@ function build_blas( # Infer backend from input array type backend = KA.get_backend(primitives) + init = Bounds3() # Compute scene AABB (works on both CPU and GPU arrays) - scene_aabb = mapreduce(world_bound, ∪, primitives, init=Bounds3()) + scene_aabb = AK.mapreduce(world_bound, ∪, primitives; init=init, neutral=init) + scene_min = scene_aabb.p_min scene_extent = Vec3f(scene_aabb.p_max - scene_aabb.p_min)