git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitb583b4fbe7d6e4ce67b42de3a1e7e1012c8870c4
parent8dbe0c4046
authorLucas Galante <[email protected]>
date2026-07-14 18:54
feat(vk): tier-2 hardware ray queries + CCE_VK_DEVICE selection (phase 4)

The RT engine's fast path: on devices with the ray-query stack
(VK_KHR_acceleration_structure + ray_query + deferred_host_operations,
BDA, API >= 1.2 — detected and enabled at device creation, exposed as
VkCore::accel_loader), RtStage builds a driver BLAS over the triangle
buffer (opaque, stride 16, no index buffer) plus a one-instance TLAS,
and traces via rayQuery intrinsics — engaging RT cores. The shader
splits at exactly the planned seam: rt_common.wgsl (params, buffers,
accumulation, shading, cs_main) + rt_bvh.wgsl (tier 1, unchanged
traversal) or rt_query.wgsl (tier 2), concatenated at pipeline
creation; tier 2 compiles through naga's RAY_QUERY capability to
SPIR-V 1.4. CCE_VK_RT=compute forces tier 1 for A/B.

Device selection: CCE_VK_DEVICE=integrated|discrete|<name substring>
reranks physical devices (default stays integrated); an explicit
request also lifts a session-wide VK_DRIVER_FILES/VK_ICD_FILENAMES
ICD pin for this process, since that pin is the usual reason the
discrete GPU is invisible. Unsatisfiable preferences fall back to the
default order.

Verified on the RTX 4080 Laptop GPU (driver 610.43.02) with validation
layers, zero messages: offscreen GPU test, thumbnail render (0.55s
wall vs ~3s on the iGPU), and windowed vk-smoke presenting via PRIME.
Cross-tier parity: 96-spp thumbnails from tier 1 (Iris Xe) and tier 2
(4080) differ by 0.028% MAE, zero pixels beyond 8% fuzz. All 174 tests
pass; Intel default path unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01G5djCURa3LVnRU8WacanLC

 src/vk/core.rs                     | 120 +++++++++-
 src/vk/renderer.rs                 |  43 +++-
 src/vk/rt.rs                       | 459 +++++++++++++++++++++++++++++++++++--
 src/vk/rt_bvh.wgsl                 | 123 ++++++++++
 src/vk/{rt.wgsl => rt_common.wgsl} | 144 ++----------
 src/vk/rt_query.wgsl               |  19 ++
 6 files changed, 746 insertions(+), 162 deletions(-)

diff --git a/src/vk/core.rs b/src/vk/core.rs
index f4eedbd..3a23ff2 100644
--- a/src/vk/core.rs
+++ b/src/vk/core.rs
@@ -50,6 +50,10 @@ pub struct VkCore {
     pub(crate) queue: vk::Queue,
     #[allow(dead_code)] // RT engine / future consumers select by family
     pub(crate) queue_family: u32,
+    /// The VK_KHR_acceleration_structure device loader — present exactly when
+    /// the ray-query stack (accel structs + ray_query + BDA) was enabled at
+    /// device creation. Its presence IS the tier-2 capability signal.
+    pub(crate) accel_loader: Option<ash::khr::acceleration_structure::Device>,
     pub(crate) device: ash::Device,
     pub(crate) physical_device: vk::PhysicalDevice,
     pub(crate) surface_loader: ash::khr::surface::Instance,
@@ -57,6 +61,8 @@ pub struct VkCore {
     pub(crate) instance: ash::Instance,
     pub(crate) _entry: ash::Entry,
     pub(crate) min_uniform_align: vk::DeviceSize,
+    /// minAccelerationStructureScratchOffsetAlignment; 1 when no ray-query stack.
+    pub(crate) as_scratch_align: vk::DeviceSize,
 }
 
 impl VkCore {
@@ -84,6 +90,20 @@ impl VkCore {
     unsafe fn new_inner(
         wayland: Option<(*mut c_void, *mut c_void)>,
     ) -> (Self, Option<vk::SurfaceKHR>) {
+        // CCE_VK_DEVICE: "integrated" (the default), "discrete", or a device
+        // name substring. An explicit request also lifts a session-wide ICD
+        // pin (VK_DRIVER_FILES / VK_ICD_FILENAMES) for THIS process — the
+        // common setup pins Vulkan to the iGPU to keep the dGPU asleep, which
+        // would otherwise make "discrete" unsatisfiable.
+        let device_pref = std::env::var("CCE_VK_DEVICE")
+            .ok()
+            .map(|v| v.to_lowercase())
+            .filter(|v| !v.is_empty());
+        if device_pref.is_some() {
+            std::env::remove_var("VK_DRIVER_FILES");
+            std::env::remove_var("VK_ICD_FILENAMES");
+        }
+
         let entry = ash::Entry::load().expect("Failed to load libvulkan");
 
         // Validation when available (debug builds or CCE_VK_VALIDATION=1).
@@ -180,7 +200,9 @@ impl VkCore {
         });
 
         // Physical device + queue family: graphics, plus present support when
-        // a surface exists. Prefer integrated (the toolkit's LowPower default).
+        // a surface exists. Prefer integrated (the toolkit's LowPower default)
+        // unless CCE_VK_DEVICE says otherwise; an unsatisfiable preference
+        // falls back to the default order rather than failing.
         let mut candidates: Vec<(vk::PhysicalDevice, u32, i32)> = Vec::new();
         for pd in instance
             .enumerate_physical_devices()
@@ -199,12 +221,35 @@ impl VkCore {
             });
             if let Some(family) = family {
                 let props = instance.get_physical_device_properties(pd);
-                let rank = match props.device_type {
+                let name = CStr::from_ptr(props.device_name.as_ptr())
+                    .to_string_lossy()
+                    .to_lowercase();
+                let type_rank = match props.device_type {
                     vk::PhysicalDeviceType::INTEGRATED_GPU => 0,
                     vk::PhysicalDeviceType::DISCRETE_GPU => 1,
                     vk::PhysicalDeviceType::VIRTUAL_GPU => 2,
                     _ => 3,
                 };
+                let rank = match device_pref.as_deref() {
+                    Some("discrete") => match props.device_type {
+                        vk::PhysicalDeviceType::DISCRETE_GPU => 0,
+                        other => {
+                            1 + match other {
+                                vk::PhysicalDeviceType::INTEGRATED_GPU => 0,
+                                vk::PhysicalDeviceType::VIRTUAL_GPU => 2,
+                                _ => 3,
+                            }
+                        }
+                    },
+                    Some("integrated") | None => type_rank,
+                    Some(substr) => {
+                        if name.contains(substr) {
+                            0
+                        } else {
+                            1 + type_rank
+                        }
+                    }
+                };
                 candidates.push((pd, family, rank));
             }
         }
@@ -226,28 +271,87 @@ impl VkCore {
         let queue_infos = [vk::DeviceQueueCreateInfo::default()
             .queue_family_index(queue_family)
             .queue_priorities(&queue_priorities)];
-        let device_extensions: Vec<*const i8> = if wayland.is_some() {
+        let mut device_extensions: Vec<*const i8> = if wayland.is_some() {
             vec![ash::khr::swapchain::NAME.as_ptr()]
         } else {
             Vec::new()
         };
+
+        // The ray-query stack (the RT engine's tier 2): needs the three
+        // extensions plus the BDA / accel-structure / ray-query features and
+        // an API >= 1.2 device (SPIR-V 1.4 shaders). Enabled whenever the
+        // device offers it; consumers check `accel_loader`.
+        let ext_props = instance
+            .enumerate_device_extension_properties(physical_device)
+            .unwrap_or_default();
+        let has_ext = |name: &CStr| {
+            ext_props
+                .iter()
+                .any(|e| CStr::from_ptr(e.extension_name.as_ptr()) == name)
+        };
+        let device_api = instance
+            .get_physical_device_properties(physical_device)
+            .api_version
+            .min(api_version);
+        let mut ray_query = device_api >= vk::API_VERSION_1_2
+            && has_ext(ash::khr::acceleration_structure::NAME)
+            && has_ext(ash::khr::ray_query::NAME)
+            && has_ext(ash::khr::deferred_host_operations::NAME);
+        if ray_query {
+            let mut bda = vk::PhysicalDeviceBufferDeviceAddressFeatures::default();
+            let mut asf = vk::PhysicalDeviceAccelerationStructureFeaturesKHR::default();
+            let mut rqf = vk::PhysicalDeviceRayQueryFeaturesKHR::default();
+            let mut features2 = vk::PhysicalDeviceFeatures2::default()
+                .push_next(&mut bda)
+                .push_next(&mut asf)
+                .push_next(&mut rqf);
+            instance.get_physical_device_features2(physical_device, &mut features2);
+            ray_query = bda.buffer_device_address == vk::TRUE
+                && asf.acceleration_structure == vk::TRUE
+                && rqf.ray_query == vk::TRUE;
+        }
+
+        let mut bda_features =
+            vk::PhysicalDeviceBufferDeviceAddressFeatures::default().buffer_device_address(true);
+        let mut as_features = vk::PhysicalDeviceAccelerationStructureFeaturesKHR::default()
+            .acceleration_structure(true);
+        let mut rq_features = vk::PhysicalDeviceRayQueryFeaturesKHR::default().ray_query(true);
+        let mut device_info = vk::DeviceCreateInfo::default().queue_create_infos(&queue_infos);
+        if ray_query {
+            device_extensions.push(ash::khr::acceleration_structure::NAME.as_ptr());
+            device_extensions.push(ash::khr::ray_query::NAME.as_ptr());
+            device_extensions.push(ash::khr::deferred_host_operations::NAME.as_ptr());
+            device_info = device_info
+                .push_next(&mut bda_features)
+                .push_next(&mut as_features)
+                .push_next(&mut rq_features);
+            log::info!("Vulkan ray-query stack enabled (RT tier 2 available)");
+        }
         let device = instance
             .create_device(
                 physical_device,
-                &vk::DeviceCreateInfo::default()
-                    .queue_create_infos(&queue_infos)
-                    .enabled_extension_names(&device_extensions),
+                &device_info.enabled_extension_names(&device_extensions),
                 None,
             )
             .expect("Failed to create Vulkan device");
         let queue = device.get_device_queue(queue_family, 0);
+        let accel_loader =
+            ray_query.then(|| ash::khr::acceleration_structure::Device::new(&instance, &device));
+        let as_scratch_align = if ray_query {
+            let mut as_props = vk::PhysicalDeviceAccelerationStructurePropertiesKHR::default();
+            let mut props2 = vk::PhysicalDeviceProperties2::default().push_next(&mut as_props);
+            instance.get_physical_device_properties2(physical_device, &mut props2);
+            (as_props.min_acceleration_structure_scratch_offset_alignment as vk::DeviceSize).max(1)
+        } else {
+            1
+        };
 
         let allocator = Allocator::new(&AllocatorCreateDesc {
             instance: instance.clone(),
             device: device.clone(),
             physical_device,
             debug_settings: Default::default(),
-            buffer_device_address: false,
+            buffer_device_address: ray_query,
             allocation_sizes: Default::default(),
         })
         .expect("Failed to create GPU allocator");
@@ -267,6 +371,7 @@ impl VkCore {
                 command_pool,
                 queue,
                 queue_family,
+                accel_loader,
                 device,
                 physical_device,
                 surface_loader,
@@ -274,6 +379,7 @@ impl VkCore {
                 instance,
                 _entry: entry,
                 min_uniform_align,
+                as_scratch_align,
             },
             surface,
         )
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 96ee42a..5d6b862 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -187,6 +187,25 @@ pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
     naga::back::spv::write_vec(&module, &info, &options, None).expect("SPIR-V write failed")
 }
 
+/// Like [`compile_wgsl`], but with naga's RAY_QUERY capability and SPIR-V 1.4
+/// (required by SPV_KHR_ray_query). Only used on devices where the ray-query
+/// device stack was enabled — those are Vulkan 1.2+, which accepts 1.4.
+pub(crate) fn compile_wgsl_ray_query(source: &str) -> Vec<u32> {
+    let module = naga::front::wgsl::parse_str(source).expect("WGSL parse failed");
+    let info = naga::valid::Validator::new(
+        naga::valid::ValidationFlags::all(),
+        naga::valid::Capabilities::RAY_QUERY,
+    )
+    .validate(&module)
+    .expect("WGSL validation failed");
+    let options = naga::back::spv::Options {
+        lang_version: (1, 4),
+        flags: naga::back::spv::WriterFlags::LABEL_VARYINGS,
+        ..Default::default()
+    };
+    naga::back::spv::write_vec(&module, &info, &options, None).expect("SPIR-V write failed")
+}
+
 const COLOR_RANGE: vk::ImageSubresourceRange = vk::ImageSubresourceRange {
     aspect_mask: vk::ImageAspectFlags::COLOR,
     base_mip_level: 0,
@@ -897,11 +916,25 @@ impl VkRenderer {
         unsafe {
             let _ = self.core.device.device_wait_idle();
         }
-        let allocator = self.core.allocator.as_mut().unwrap();
-        let rt = self
-            .rt
-            .get_or_insert_with(|| RtStage::new(&self.core.device, allocator, FRAMES_IN_FLIGHT));
-        rt.set_scene(&self.core.device, allocator, triangles, materials);
+        let core = &mut self.core;
+        let allocator = core.allocator.as_mut().unwrap();
+        let rt = self.rt.get_or_insert_with(|| {
+            RtStage::new(
+                &core.device,
+                allocator,
+                FRAMES_IN_FLIGHT,
+                core.accel_loader.as_ref(),
+                core.as_scratch_align,
+            )
+        });
+        rt.set_scene(
+            &core.device,
+            allocator,
+            core.queue,
+            core.command_pool,
+            triangles,
+            materials,
+        );
     }
 
     /// Stage one progressive path-tracing pass into the viewport pane
diff --git a/src/vk/rt.rs b/src/vk/rt.rs
index 1808f6f..da52f59 100644
--- a/src/vk/rt.rs
+++ b/src/vk/rt.rs
@@ -16,7 +16,9 @@ use ash::vk;
 use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
 use gpu_allocator::MemoryLocation;
 
-use super::renderer::{compile_wgsl, create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
+use super::renderer::{
+    compile_wgsl, compile_wgsl_ray_query, create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer,
+};
 
 /// One triangle of an RT scene, in the same space as the camera's `inv_mvp`
 /// (for the designer: mesh space, the space `Vertex3D` positions live in).
@@ -273,12 +275,37 @@ const MAX_SAMPLES: u32 = 1024;
 const MAX_BOUNCES: u32 = 4;
 const WORKGROUP: u32 = 8;
 
+/// The two trace backends. They share every shader line except
+/// `intersect_scene` (rt_bvh.wgsl vs rt_query.wgsl) and binding 1.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RtTier {
+    /// CPU-built BVH traversed in compute — runs on any device.
+    Compute,
+    /// Driver acceleration structures + VK_KHR_ray_query — RT cores.
+    RayQuery,
+}
+
+/// Tier-2 GPU objects: one BLAS over the triangle buffer, a one-instance
+/// TLAS over it. Rebuilt wholesale on every scene replacement.
+struct Accel {
+    blas: vk::AccelerationStructureKHR,
+    blas_buffer: AllocatedBuffer,
+    tlas: vk::AccelerationStructureKHR,
+    tlas_buffer: AllocatedBuffer,
+    instances: AllocatedBuffer,
+}
+
 struct RtFrame {
     uniforms: AllocatedBuffer,
     descriptor_set: vk::DescriptorSet,
 }
 
 pub(crate) struct RtStage {
+    tier: RtTier,
+    accel_loader: Option<ash::khr::acceleration_structure::Device>,
+    as_scratch_align: vk::DeviceSize,
+    accel: Option<Accel>,
+
     pipeline: vk::Pipeline,
     pipeline_layout: vk::PipelineLayout,
     descriptor_set_layout: vk::DescriptorSetLayout,
@@ -308,7 +335,32 @@ pub(crate) struct RtStage {
 }
 
 impl RtStage {
-    pub(crate) fn new(device: &ash::Device, allocator: &mut Allocator, frames_in_flight: usize) -> Self {
+    /// `accel_loader` present means the device has the ray-query stack; the
+    /// stage then runs tier 2 unless `CCE_VK_RT=compute` forces the BVH tier.
+    pub(crate) fn new(
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        frames_in_flight: usize,
+        accel_loader: Option<&ash::khr::acceleration_structure::Device>,
+        as_scratch_align: vk::DeviceSize,
+    ) -> Self {
+        let force_compute = std::env::var("CCE_VK_RT").is_ok_and(|v| v == "compute");
+        let tier = if accel_loader.is_some() && !force_compute {
+            RtTier::RayQuery
+        } else {
+            RtTier::Compute
+        };
+        log::info!(
+            "RT stage: {} tier",
+            match tier {
+                RtTier::Compute => "compute (BVH)",
+                RtTier::RayQuery => "ray-query (hardware)",
+            }
+        );
+        let binding1_type = match tier {
+            RtTier::Compute => vk::DescriptorType::STORAGE_BUFFER,
+            RtTier::RayQuery => vk::DescriptorType::ACCELERATION_STRUCTURE_KHR,
+        };
         unsafe {
             let bindings = [
                 vk::DescriptorSetLayoutBinding::default()
@@ -318,7 +370,7 @@ impl RtStage {
                     .stage_flags(vk::ShaderStageFlags::COMPUTE),
                 vk::DescriptorSetLayoutBinding::default()
                     .binding(1)
-                    .descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
+                    .descriptor_type(binding1_type)
                     .descriptor_count(1)
                     .stage_flags(vk::ShaderStageFlags::COMPUTE),
                 vk::DescriptorSetLayoutBinding::default()
@@ -356,7 +408,18 @@ impl RtStage {
                 )
                 .expect("Failed to create RT pipeline layout");
 
-            let spirv = compile_wgsl(include_str!("rt.wgsl"));
+            let spirv = match tier {
+                RtTier::Compute => compile_wgsl(&format!(
+                    "{}\n{}",
+                    include_str!("rt_common.wgsl"),
+                    include_str!("rt_bvh.wgsl")
+                )),
+                RtTier::RayQuery => compile_wgsl_ray_query(&format!(
+                    "{}\n{}",
+                    include_str!("rt_common.wgsl"),
+                    include_str!("rt_query.wgsl")
+                )),
+            };
             let shader_module = device
                 .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spirv), None)
                 .expect("Failed to create RT shader module");
@@ -376,7 +439,7 @@ impl RtStage {
                 .expect("Failed to create RT compute pipeline")[0];
 
             let n = frames_in_flight as u32;
-            let pool_sizes = [
+            let mut pool_sizes = vec![
                 vk::DescriptorPoolSize::default()
                     .ty(vk::DescriptorType::UNIFORM_BUFFER)
                     .descriptor_count(n),
@@ -387,6 +450,13 @@ impl RtStage {
                     .ty(vk::DescriptorType::STORAGE_IMAGE)
                     .descriptor_count(n),
             ];
+            if tier == RtTier::RayQuery {
+                pool_sizes.push(
+                    vk::DescriptorPoolSize::default()
+                        .ty(vk::DescriptorType::ACCELERATION_STRUCTURE_KHR)
+                        .descriptor_count(n),
+                );
+            }
             let descriptor_pool = device
                 .create_descriptor_pool(
                     &vk::DescriptorPoolCreateInfo::default()
@@ -431,6 +501,10 @@ impl RtStage {
                 .collect();
 
             RtStage {
+                tier,
+                accel_loader: accel_loader.cloned(),
+                as_scratch_align,
+                accel: None,
                 pipeline,
                 pipeline_layout,
                 descriptor_set_layout,
@@ -457,17 +531,23 @@ impl RtStage {
         }
     }
 
-    /// Replace the scene: build the BVH (reorders a copy of the triangles) and
-    /// upload nodes/triangles/materials. Caller must have the device idle.
+    /// Replace the scene. Tier 1 builds the BVH on the CPU (reordering a copy
+    /// of the triangles); tier 2 builds driver acceleration structures on the
+    /// given queue instead. Caller must have the device idle.
     pub(crate) fn set_scene(
         &mut self,
         device: &ash::Device,
         allocator: &mut Allocator,
+        queue: vk::Queue,
+        command_pool: vk::CommandPool,
         triangles: &[RtTriangle],
         materials: &[RtMaterial],
     ) {
         let mut tris: Vec<RtTriangle> = triangles.to_vec();
-        let nodes = build_bvh(&mut tris);
+        let nodes = match self.tier {
+            RtTier::Compute => build_bvh(&mut tris),
+            RtTier::RayQuery => Vec::new(),
+        };
         let gpu_tris: Vec<GpuTriangle> = tris
             .iter()
             .map(|t| GpuTriangle {
@@ -488,15 +568,20 @@ impl RtStage {
                 .collect()
         };
 
+        self.destroy_accel(device, allocator);
         for buf in [&mut self.nodes, &mut self.tris, &mut self.materials] {
             destroy_cpu_buffer(device, allocator, buf);
         }
-        let upload = |allocator: &mut Allocator, bytes: &[u8], name: &str| -> AllocatedBuffer {
+        let upload = |allocator: &mut Allocator,
+                      bytes: &[u8],
+                      usage: vk::BufferUsageFlags,
+                      name: &str|
+         -> AllocatedBuffer {
             let mut buf = create_cpu_buffer(
                 device,
                 allocator,
                 (bytes.len() as vk::DeviceSize).max(64),
-                vk::BufferUsageFlags::STORAGE_BUFFER,
+                usage,
                 name,
             );
             if !bytes.is_empty() {
@@ -505,15 +590,74 @@ impl RtStage {
             }
             buf
         };
-        self.nodes = upload(allocator, bytemuck::cast_slice(&nodes), "rt-nodes");
-        self.tris = upload(allocator, bytemuck::cast_slice(&gpu_tris), "rt-tris");
-        self.materials = upload(allocator, bytemuck::cast_slice(&gpu_mats), "rt-materials");
+        // Tier 2 reads the same triangle buffer as BLAS build input (the
+        // shading data still comes through the storage binding).
+        let tri_usage = match self.tier {
+            RtTier::Compute => vk::BufferUsageFlags::STORAGE_BUFFER,
+            RtTier::RayQuery => {
+                vk::BufferUsageFlags::STORAGE_BUFFER
+                    | vk::BufferUsageFlags::SHADER_DEVICE_ADDRESS
+                    | vk::BufferUsageFlags::ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_KHR
+            }
+        };
+        self.tris = upload(allocator, bytemuck::cast_slice(&gpu_tris), tri_usage, "rt-tris");
+        self.materials = upload(
+            allocator,
+            bytemuck::cast_slice(&gpu_mats),
+            vk::BufferUsageFlags::STORAGE_BUFFER,
+            "rt-materials",
+        );
         self.tri_count = tris.len() as u32;
         self.sample_index = 0;
 
+        // Binding 1 (per tier), then the shared 2/3.
+        match self.tier {
+            RtTier::Compute => {
+                self.nodes = upload(
+                    allocator,
+                    bytemuck::cast_slice(&nodes),
+                    vk::BufferUsageFlags::STORAGE_BUFFER,
+                    "rt-nodes",
+                );
+                for frame in &self.frames {
+                    let infos = [vk::DescriptorBufferInfo::default()
+                        .buffer(self.nodes.buffer)
+                        .range(vk::WHOLE_SIZE)];
+                    unsafe {
+                        device.update_descriptor_sets(
+                            &[vk::WriteDescriptorSet::default()
+                                .dst_set(frame.descriptor_set)
+                                .dst_binding(1)
+                                .descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
+                                .buffer_info(&infos)],
+                            &[],
+                        );
+                    }
+                }
+            }
+            RtTier::RayQuery => {
+                if self.tri_count > 0 {
+                    self.build_accel(device, allocator, queue, command_pool);
+                    let accel = self.accel.as_ref().unwrap();
+                    let handles = [accel.tlas];
+                    for frame in &self.frames {
+                        let mut as_info =
+                            vk::WriteDescriptorSetAccelerationStructureKHR::default()
+                                .acceleration_structures(&handles);
+                        let mut write = vk::WriteDescriptorSet::default()
+                            .dst_set(frame.descriptor_set)
+                            .dst_binding(1)
+                            .descriptor_type(vk::DescriptorType::ACCELERATION_STRUCTURE_KHR)
+                            .push_next(&mut as_info);
+                        write.descriptor_count = 1;
+                        unsafe { device.update_descriptor_sets(&[write], &[]) };
+                    }
+                }
+            }
+        }
+
         for frame in &self.frames {
             let infos = [
-                vk::DescriptorBufferInfo::default().buffer(self.nodes.buffer).range(vk::WHOLE_SIZE),
                 vk::DescriptorBufferInfo::default().buffer(self.tris.buffer).range(vk::WHOLE_SIZE),
                 vk::DescriptorBufferInfo::default()
                     .buffer(self.materials.buffer)
@@ -525,7 +669,7 @@ impl RtStage {
                 .map(|(i, info)| {
                     vk::WriteDescriptorSet::default()
                         .dst_set(frame.descriptor_set)
-                        .dst_binding(1 + i as u32)
+                        .dst_binding(2 + i as u32)
                         .descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
                         .buffer_info(std::slice::from_ref(info))
                 })
@@ -534,6 +678,256 @@ impl RtStage {
         }
     }
 
+    /// Build the BLAS (over `self.tris`, opaque triangles) and a one-instance
+    /// TLAS, on the given queue with a blocking one-time submit. Device is
+    /// idle (set_scene contract), so replacing old structures is safe.
+    fn build_accel(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        queue: vk::Queue,
+        command_pool: vk::CommandPool,
+    ) {
+        let loader = self.accel_loader.clone().expect("tier 2 without accel loader");
+        let create_as_buffer = |allocator: &mut Allocator,
+                                size: vk::DeviceSize,
+                                usage: vk::BufferUsageFlags,
+                                name: &str|
+         -> AllocatedBuffer {
+            unsafe {
+                let buffer = device
+                    .create_buffer(
+                        &vk::BufferCreateInfo::default()
+                            .size(size)
+                            .usage(usage | vk::BufferUsageFlags::SHADER_DEVICE_ADDRESS)
+                            .sharing_mode(vk::SharingMode::EXCLUSIVE),
+                        None,
+                    )
+                    .expect("Failed to create AS buffer");
+                let requirements = device.get_buffer_memory_requirements(buffer);
+                let allocation = allocator
+                    .allocate(&AllocationCreateDesc {
+                        name,
+                        requirements,
+                        location: MemoryLocation::GpuOnly,
+                        linear: true,
+                        allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+                    })
+                    .expect("Failed to allocate AS memory");
+                device
+                    .bind_buffer_memory(buffer, allocation.memory(), allocation.offset())
+                    .expect("Failed to bind AS memory");
+                AllocatedBuffer { buffer, allocation: Some(allocation), size }
+            }
+        };
+        let addr_of = |buffer: vk::Buffer| unsafe {
+            device.get_buffer_device_address(&vk::BufferDeviceAddressInfo::default().buffer(buffer))
+        };
+
+        unsafe {
+            // --- BLAS over the triangle buffer (stride 16: p0/p1/p2 vec4s).
+            let tri_addr = addr_of(self.tris.buffer);
+            let blas_geometry = vk::AccelerationStructureGeometryKHR::default()
+                .geometry_type(vk::GeometryTypeKHR::TRIANGLES)
+                .flags(vk::GeometryFlagsKHR::OPAQUE)
+                .geometry(vk::AccelerationStructureGeometryDataKHR {
+                    triangles: vk::AccelerationStructureGeometryTrianglesDataKHR::default()
+                        .vertex_format(vk::Format::R32G32B32_SFLOAT)
+                        .vertex_data(vk::DeviceOrHostAddressConstKHR { device_address: tri_addr })
+                        .vertex_stride(16)
+                        .max_vertex(self.tri_count * 3 - 1)
+                        .index_type(vk::IndexType::NONE_KHR),
+                });
+            let blas_geometries = [blas_geometry];
+            let mut blas_build = vk::AccelerationStructureBuildGeometryInfoKHR::default()
+                .ty(vk::AccelerationStructureTypeKHR::BOTTOM_LEVEL)
+                .flags(vk::BuildAccelerationStructureFlagsKHR::PREFER_FAST_TRACE)
+                .mode(vk::BuildAccelerationStructureModeKHR::BUILD)
+                .geometries(&blas_geometries);
+            let blas_sizes = {
+                let mut sizes = vk::AccelerationStructureBuildSizesInfoKHR::default();
+                loader.get_acceleration_structure_build_sizes(
+                    vk::AccelerationStructureBuildTypeKHR::DEVICE,
+                    &blas_build,
+                    &[self.tri_count],
+                    &mut sizes,
+                );
+                sizes
+            };
+            let blas_buffer = create_as_buffer(
+                allocator,
+                blas_sizes.acceleration_structure_size,
+                vk::BufferUsageFlags::ACCELERATION_STRUCTURE_STORAGE_KHR,
+                "rt-blas",
+            );
+            let blas = loader
+                .create_acceleration_structure(
+                    &vk::AccelerationStructureCreateInfoKHR::default()
+                        .buffer(blas_buffer.buffer)
+                        .size(blas_sizes.acceleration_structure_size)
+                        .ty(vk::AccelerationStructureTypeKHR::BOTTOM_LEVEL),
+                    None,
+                )
+                .expect("Failed to create BLAS");
+
+            // --- One-instance TLAS.
+            let blas_addr = loader.get_acceleration_structure_device_address(
+                &vk::AccelerationStructureDeviceAddressInfoKHR::default()
+                    .acceleration_structure(blas),
+            );
+            let instance = vk::AccelerationStructureInstanceKHR {
+                transform: vk::TransformMatrixKHR {
+                    matrix: [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
+                },
+                instance_custom_index_and_mask: vk::Packed24_8::new(0, 0xff),
+                instance_shader_binding_table_record_offset_and_flags: vk::Packed24_8::new(0, 0),
+                acceleration_structure_reference: vk::AccelerationStructureReferenceKHR {
+                    device_handle: blas_addr,
+                },
+            };
+            let instance_bytes = std::slice::from_raw_parts(
+                (&instance as *const vk::AccelerationStructureInstanceKHR).cast::<u8>(),
+                std::mem::size_of::<vk::AccelerationStructureInstanceKHR>(),
+            );
+            let mut instances = create_cpu_buffer(
+                device,
+                allocator,
+                instance_bytes.len() as vk::DeviceSize,
+                vk::BufferUsageFlags::SHADER_DEVICE_ADDRESS
+                    | vk::BufferUsageFlags::ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_KHR,
+                "rt-tlas-instances",
+            );
+            instances.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()
+                [..instance_bytes.len()]
+                .copy_from_slice(instance_bytes);
+
+            let tlas_geometry = vk::AccelerationStructureGeometryKHR::default()
+                .geometry_type(vk::GeometryTypeKHR::INSTANCES)
+                .geometry(vk::AccelerationStructureGeometryDataKHR {
+                    instances: vk::AccelerationStructureGeometryInstancesDataKHR::default()
+                        .array_of_pointers(false)
+                        .data(vk::DeviceOrHostAddressConstKHR {
+                            device_address: addr_of(instances.buffer),
+                        }),
+                });
+            let tlas_geometries = [tlas_geometry];
+            let mut tlas_build = vk::AccelerationStructureBuildGeometryInfoKHR::default()
+                .ty(vk::AccelerationStructureTypeKHR::TOP_LEVEL)
+                .flags(vk::BuildAccelerationStructureFlagsKHR::PREFER_FAST_TRACE)
+                .mode(vk::BuildAccelerationStructureModeKHR::BUILD)
+                .geometries(&tlas_geometries);
+            let tlas_sizes = {
+                let mut sizes = vk::AccelerationStructureBuildSizesInfoKHR::default();
+                loader.get_acceleration_structure_build_sizes(
+                    vk::AccelerationStructureBuildTypeKHR::DEVICE,
+                    &tlas_build,
+                    &[1],
+                    &mut sizes,
+                );
+                sizes
+            };
+            let tlas_buffer = create_as_buffer(
+                allocator,
+                tlas_sizes.acceleration_structure_size,
+                vk::BufferUsageFlags::ACCELERATION_STRUCTURE_STORAGE_KHR,
+                "rt-tlas",
+            );
+            let tlas = loader
+                .create_acceleration_structure(
+                    &vk::AccelerationStructureCreateInfoKHR::default()
+                        .buffer(tlas_buffer.buffer)
+                        .size(tlas_sizes.acceleration_structure_size)
+                        .ty(vk::AccelerationStructureTypeKHR::TOP_LEVEL),
+                    None,
+                )
+                .expect("Failed to create TLAS");
+
+            // Shared scratch, aligned to the device's scratch requirement
+            // (buffer device addresses only guarantee allocation alignment).
+            let scratch_size =
+                blas_sizes.build_scratch_size.max(tlas_sizes.build_scratch_size);
+            let mut scratch = create_as_buffer(
+                allocator,
+                scratch_size + self.as_scratch_align,
+                vk::BufferUsageFlags::STORAGE_BUFFER,
+                "rt-as-scratch",
+            );
+            let scratch_addr =
+                addr_of(scratch.buffer).next_multiple_of(self.as_scratch_align.max(1));
+
+            blas_build = blas_build
+                .dst_acceleration_structure(blas)
+                .scratch_data(vk::DeviceOrHostAddressKHR { device_address: scratch_addr });
+            tlas_build = tlas_build
+                .dst_acceleration_structure(tlas)
+                .scratch_data(vk::DeviceOrHostAddressKHR { device_address: scratch_addr });
+
+            // One-time submit: BLAS build → barrier → TLAS build.
+            let cmd = device
+                .allocate_command_buffers(
+                    &vk::CommandBufferAllocateInfo::default()
+                        .command_pool(command_pool)
+                        .level(vk::CommandBufferLevel::PRIMARY)
+                        .command_buffer_count(1),
+                )
+                .expect("Failed to allocate AS build command buffer")[0];
+            device
+                .begin_command_buffer(
+                    cmd,
+                    &vk::CommandBufferBeginInfo::default()
+                        .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
+                )
+                .unwrap();
+            let blas_range = [vk::AccelerationStructureBuildRangeInfoKHR::default()
+                .primitive_count(self.tri_count)];
+            loader.cmd_build_acceleration_structures(cmd, &[blas_build], &[&blas_range]);
+            device.cmd_pipeline_barrier(
+                cmd,
+                vk::PipelineStageFlags::ACCELERATION_STRUCTURE_BUILD_KHR,
+                vk::PipelineStageFlags::ACCELERATION_STRUCTURE_BUILD_KHR,
+                vk::DependencyFlags::empty(),
+                &[vk::MemoryBarrier::default()
+                    .src_access_mask(vk::AccessFlags::ACCELERATION_STRUCTURE_WRITE_KHR)
+                    .dst_access_mask(
+                        vk::AccessFlags::ACCELERATION_STRUCTURE_READ_KHR
+                            | vk::AccessFlags::ACCELERATION_STRUCTURE_WRITE_KHR,
+                    )],
+                &[],
+                &[],
+            );
+            let tlas_range =
+                [vk::AccelerationStructureBuildRangeInfoKHR::default().primitive_count(1)];
+            loader.cmd_build_acceleration_structures(cmd, &[tlas_build], &[&tlas_range]);
+            device.end_command_buffer(cmd).unwrap();
+            let cmds = [cmd];
+            device
+                .queue_submit(
+                    queue,
+                    &[vk::SubmitInfo::default().command_buffers(&cmds)],
+                    vk::Fence::null(),
+                )
+                .expect("AS build submit failed");
+            let _ = device.queue_wait_idle(queue);
+            device.free_command_buffers(command_pool, &cmds);
+            destroy_cpu_buffer(device, allocator, &mut scratch);
+
+            self.accel = Some(Accel { blas, blas_buffer, tlas, tlas_buffer, instances });
+        }
+    }
+
+    fn destroy_accel(&mut self, device: &ash::Device, allocator: &mut Allocator) {
+        if let Some(mut accel) = self.accel.take() {
+            let loader = self.accel_loader.as_ref().expect("accel without loader");
+            unsafe {
+                loader.destroy_acceleration_structure(accel.tlas, None);
+                loader.destroy_acceleration_structure(accel.blas, None);
+            }
+            destroy_cpu_buffer(device, allocator, &mut accel.tlas_buffer);
+            destroy_cpu_buffer(device, allocator, &mut accel.blas_buffer);
+            destroy_cpu_buffer(device, allocator, &mut accel.instances);
+        }
+    }
+
     /// Stage an RT frame for the viewport pane (physical pixels). Recreates the
     /// pane-sized targets on size change (waits for device idle) and resets the
     /// accumulation when the camera, size, or scene changed.
@@ -916,6 +1310,7 @@ impl RtStage {
 
     pub(crate) fn destroy(&mut self, device: &ash::Device, allocator: &mut Allocator) {
         self.destroy_targets(device, allocator);
+        self.destroy_accel(device, allocator);
         for buf in [&mut self.nodes, &mut self.tris, &mut self.materials] {
             destroy_cpu_buffer(device, allocator, buf);
         }
@@ -963,8 +1358,10 @@ impl RtOffscreen {
     pub fn new() -> Self {
         let mut core = super::VkCore::new_headless();
         let device = core.device.clone();
+        let accel_loader = core.accel_loader.clone();
+        let as_scratch_align = core.as_scratch_align;
         let allocator = core.allocator.as_mut().unwrap();
-        let stage = RtStage::new(&device, allocator, 1);
+        let stage = RtStage::new(&device, allocator, 1, accel_loader.as_ref(), as_scratch_align);
         unsafe {
             let cmd = device
                 .allocate_command_buffers(
@@ -996,8 +1393,16 @@ impl RtOffscreen {
             let _ = self.core.device.device_wait_idle();
         }
         let device = self.core.device.clone();
-        self.stage
-            .set_scene(&device, self.core.allocator.as_mut().unwrap(), triangles, materials);
+        let queue = self.core.queue;
+        let command_pool = self.core.command_pool;
+        self.stage.set_scene(
+            &device,
+            self.core.allocator.as_mut().unwrap(),
+            queue,
+            command_pool,
+            triangles,
+            materials,
+        );
     }
 
     /// Render `samples` paths per pixel and return tightly packed
@@ -1436,10 +1841,20 @@ mod tests {
     }
 
     #[test]
-    fn test_rt_shader_compiles() {
-        // naga parse + validate + SPIR-V write; panics on failure.
-        let spirv = compile_wgsl(include_str!("rt.wgsl"));
-        assert!(!spirv.is_empty());
+    fn test_rt_shaders_compile() {
+        // naga parse + validate + SPIR-V write for both tiers; panics on failure.
+        let tier1 = compile_wgsl(&format!(
+            "{}\n{}",
+            include_str!("rt_common.wgsl"),
+            include_str!("rt_bvh.wgsl")
+        ));
+        assert!(!tier1.is_empty());
+        let tier2 = compile_wgsl_ray_query(&format!(
+            "{}\n{}",
+            include_str!("rt_common.wgsl"),
+            include_str!("rt_query.wgsl")
+        ));
+        assert!(!tier2.is_empty());
     }
 
     /// End-to-end GPU test — needs a Vulkan device, so ignored by default.
diff --git a/src/vk/rt_bvh.wgsl b/src/vk/rt_bvh.wgsl
new file mode 100644
index 0000000..4096345
--- /dev/null
+++ b/src/vk/rt_bvh.wgsl
@@ -0,0 +1,123 @@
+// rt_bvh.wgsl — tier-1 intersect_scene: a CPU-built binned-SAH BVH (rt.rs)
+// traversed with an explicit stack. Pure compute; runs on any device.
+// Concatenated after rt_common.wgsl at pipeline creation.
+
+// 32-byte BVH node (rt.rs `GpuBvhNode`): count > 0 marks a leaf over
+// tris[left_first .. left_first+count]; otherwise children are at
+// left_first and left_first + 1.
+struct Node {
+    bmin: vec3<f32>,
+    left_first: u32,
+    bmax: vec3<f32>,
+    count: u32,
+}
+@group(0) @binding(1) var<storage, read> nodes: array<Node>;
+
+// Slab test: entry distance, or 1e30 on miss / beyond the current hit.
+fn intersect_aabb(
+    ro: vec3<f32>,
+    inv_rd: vec3<f32>,
+    bmin: vec3<f32>,
+    bmax: vec3<f32>,
+    t_limit: f32,
+) -> f32 {
+    let t1 = (bmin - ro) * inv_rd;
+    let t2 = (bmax - ro) * inv_rd;
+    let lo = min(t1, t2);
+    let hi = max(t1, t2);
+    let tn = max(max(lo.x, lo.y), lo.z);
+    let tf = min(min(hi.x, hi.y), hi.z);
+    if tf >= max(tn, 0.0) && tn < t_limit {
+        return tn;
+    }
+    return 1e30;
+}
+
+// Möller–Trumbore, two-sided (scene triangles have no guaranteed winding).
+fn intersect_tri(ro: vec3<f32>, rd: vec3<f32>, i: u32, t_limit: f32) -> f32 {
+    let tri = tris[i];
+    let e1 = tri.p1.xyz - tri.p0.xyz;
+    let e2 = tri.p2.xyz - tri.p0.xyz;
+    let h = cross(rd, e2);
+    let a = dot(e1, h);
+    if abs(a) < 1e-8 {
+        return 1e30;
+    }
+    let f = 1.0 / a;
+    let s = ro - tri.p0.xyz;
+    let u = f * dot(s, h);
+    if u < 0.0 || u > 1.0 {
+        return 1e30;
+    }
+    let q = cross(s, e1);
+    let v = f * dot(rd, q);
+    if v < 0.0 || u + v > 1.0 {
+        return 1e30;
+    }
+    let t = f * dot(e2, q);
+    if t > 1e-4 && t < t_limit {
+        return t;
+    }
+    return 1e30;
+}
+
+// Ordered BVH traversal with an explicit stack.
+fn intersect_scene(ro: vec3<f32>, rd: vec3<f32>) -> HitInfo {
+    var hit = HitInfo(1e30, 0u);
+    if arrayLength(&nodes) == 0u {
+        return hit;
+    }
+    let inv_rd = vec3<f32>(1.0, 1.0, 1.0) / rd;
+    var stack: array<u32, 32>;
+    var sp: u32 = 0u;
+    var node_idx: u32 = 0u;
+    if intersect_aabb(ro, inv_rd, nodes[0].bmin, nodes[0].bmax, hit.t) >= 1e30 {
+        return hit;
+    }
+    loop {
+        let node = nodes[node_idx];
+        if node.count > 0u {
+            for (var i: u32 = 0u; i < node.count; i = i + 1u) {
+                let tri_idx = node.left_first + i;
+                let t = intersect_tri(ro, rd, tri_idx, hit.t);
+                if t < hit.t {
+                    hit.t = t;
+                    hit.tri = tri_idx;
+                }
+            }
+            if sp == 0u {
+                break;
+            }
+            sp = sp - 1u;
+            node_idx = stack[sp];
+            continue;
+        }
+        // Internal: visit the nearer child first, defer the farther one.
+        var near = node.left_first;
+        var far = node.left_first + 1u;
+        var t_near = intersect_aabb(ro, inv_rd, nodes[near].bmin, nodes[near].bmax, hit.t);
+        var t_far = intersect_aabb(ro, inv_rd, nodes[far].bmin, nodes[far].bmax, hit.t);
+        if t_far < t_near {
+            let tmp_i = near;
+            near = far;
+            far = tmp_i;
+            let tmp_t = t_near;
+            t_near = t_far;
+            t_far = tmp_t;
+        }
+        if t_near >= 1e30 {
+            if sp == 0u {
+                break;
+            }
+            sp = sp - 1u;
+            node_idx = stack[sp];
+            continue;
+        }
+        if t_far < 1e30 && sp < 32u {
+            stack[sp] = far;
+            sp = sp + 1u;
+        }
+        node_idx = near;
+    }
+    return hit;
+}
diff --git a/src/vk/rt.wgsl b/src/vk/rt_common.wgsl
similarity index 53%
rename from src/vk/rt.wgsl
rename to src/vk/rt_common.wgsl
index e3cbaf7..4bf826a 100644
--- a/src/vk/rt.wgsl
+++ b/src/vk/rt_common.wgsl
@@ -1,12 +1,16 @@
-// rt.wgsl — the tier-1 compute path tracer (RT-renderer phase 2).
+// rt_common.wgsl — the path tracer's shared core (RT-renderer phases 2+4).
 //
-// Pure Vulkan compute: a CPU-built BVH (see rt.rs) is traversed per ray, so
-// this runs on any device — no VK_KHR_ray_* required. One dispatch adds one
-// sample per pixel into the accumulation buffer (progressive refinement);
-// the running mean is tone-mapped (clamped linear) into `out_img`, which the
-// stage blits into the backdrop pane. Geometry, camera, and accumulation are
-// deliberately independent of the trace call so a tier-2 ray-query backend
-// can swap in `intersect_scene` later.
+// Everything except the trace call: params, scene/material buffers,
+// accumulation, RNG, sky, sampling, and cs_main. Binding 1 and
+// `intersect_scene` come from whichever tier file is concatenated after
+// this one at pipeline creation:
+//   - rt_bvh.wgsl   — tier 1: a CPU-built BVH traversed in compute; runs
+//                     on any device, no VK_KHR_ray_* required.
+//   - rt_query.wgsl — tier 2: hardware ray queries against a driver-built
+//                     TLAS (VK_KHR_ray_query), engaging RT cores.
+// One dispatch adds `spp` samples per pixel into the accumulation buffer
+// (progressive refinement); the running mean is tone-mapped (clamped
+// linear) into `out_img`, which the stage blits into the backdrop pane.
 
 struct Params {
     // Inverse of the raster path's proj*view*model: unprojects wgpu-style NDC
@@ -28,16 +32,8 @@ struct Params {
 
 @group(0) @binding(0) var<uniform> params: Params;
 
-// 32-byte BVH node (rt.rs `GpuBvhNode`): count > 0 marks a leaf over
-// tris[left_first .. left_first+count]; otherwise children are at
-// left_first and left_first + 1.
-struct Node {
-    bmin: vec3<f32>,
-    left_first: u32,
-    bmax: vec3<f32>,
-    count: u32,
-}
-@group(0) @binding(1) var<storage, read> nodes: array<Node>;
+// Binding 1 belongs to the tier file: the BVH node buffer (tier 1) or the
+// acceleration structure (tier 2).
 
 // Positions in xyz; p0.w carries the material index (bitcast).
 struct Tri {
@@ -66,121 +62,13 @@ fn rand(state: ptr<function, u32>) -> f32 {
     return f32((word >> 22u) ^ word) * (1.0 / 4294967295.0);
 }
 
-// Slab test: entry distance, or 1e30 on miss / beyond the current hit.
-fn intersect_aabb(
-    ro: vec3<f32>,
-    inv_rd: vec3<f32>,
-    bmin: vec3<f32>,
-    bmax: vec3<f32>,
-    t_limit: f32,
-) -> f32 {
-    let t1 = (bmin - ro) * inv_rd;
-    let t2 = (bmax - ro) * inv_rd;
-    let lo = min(t1, t2);
-    let hi = max(t1, t2);
-    let tn = max(max(lo.x, lo.y), lo.z);
-    let tf = min(min(hi.x, hi.y), hi.z);
-    if tf >= max(tn, 0.0) && tn < t_limit {
-        return tn;
-    }
-    return 1e30;
-}
-
-// Möller–Trumbore, two-sided (scene triangles have no guaranteed winding).
-fn intersect_tri(ro: vec3<f32>, rd: vec3<f32>, i: u32, t_limit: f32) -> f32 {
-    let tri = tris[i];
-    let e1 = tri.p1.xyz - tri.p0.xyz;
-    let e2 = tri.p2.xyz - tri.p0.xyz;
-    let h = cross(rd, e2);
-    let a = dot(e1, h);
-    if abs(a) < 1e-8 {
-        return 1e30;
-    }
-    let f = 1.0 / a;
-    let s = ro - tri.p0.xyz;
-    let u = f * dot(s, h);
-    if u < 0.0 || u > 1.0 {
-        return 1e30;
-    }
-    let q = cross(s, e1);
-    let v = f * dot(rd, q);
-    if v < 0.0 || u + v > 1.0 {
-        return 1e30;
-    }
-    let t = f * dot(e2, q);
-    if t > 1e-4 && t < t_limit {
-        return t;
-    }
-    return 1e30;
-}
-
+// The tier boundary: whichever tier file follows provides
+//   fn intersect_scene(ro: vec3<f32>, rd: vec3<f32>) -> HitInfo
 struct HitInfo {
     t: f32,
     tri: u32,
 }
 
-// Ordered BVH traversal with an explicit stack. THE tier boundary: a
-// ray-query backend replaces exactly this function.
-fn intersect_scene(ro: vec3<f32>, rd: vec3<f32>) -> HitInfo {
-    var hit = HitInfo(1e30, 0u);
-    if arrayLength(&nodes) == 0u {
-        return hit;
-    }
-    let inv_rd = vec3<f32>(1.0, 1.0, 1.0) / rd;
-    var stack: array<u32, 32>;
-    var sp: u32 = 0u;
-    var node_idx: u32 = 0u;
-    if intersect_aabb(ro, inv_rd, nodes[0].bmin, nodes[0].bmax, hit.t) >= 1e30 {
-        return hit;
-    }
-    loop {
-        let node = nodes[node_idx];
-        if node.count > 0u {
-            for (var i: u32 = 0u; i < node.count; i = i + 1u) {
-                let tri_idx = node.left_first + i;
-                let t = intersect_tri(ro, rd, tri_idx, hit.t);
-                if t < hit.t {
-                    hit.t = t;
-                    hit.tri = tri_idx;
-                }
-            }
-            if sp == 0u {
-                break;
-            }
-            sp = sp - 1u;
-            node_idx = stack[sp];
-            continue;
-        }
-        // Internal: visit the nearer child first, defer the farther one.
-        var near = node.left_first;
-        var far = node.left_first + 1u;
-        var t_near = intersect_aabb(ro, inv_rd, nodes[near].bmin, nodes[near].bmax, hit.t);
-        var t_far = intersect_aabb(ro, inv_rd, nodes[far].bmin, nodes[far].bmax, hit.t);
-        if t_far < t_near {
-            let tmp_i = near;
-            near = far;
-            far = tmp_i;
-            let tmp_t = t_near;
-            t_near = t_far;
-            t_far = tmp_t;
-        }
-        if t_near >= 1e30 {
-            if sp == 0u {
-                break;
-            }
-            sp = sp - 1u;
-            node_idx = stack[sp];
-            continue;
-        }
-        if t_far < 1e30 && sp < 32u {
-            stack[sp] = far;
-            sp = sp + 1u;
-        }
-        node_idx = near;
-    }
-    return hit;
-}
-
 // A soft studio sky: vertical gradient plus one warm key light. This is the
 // only light source until emissive geometry shows up in scenes.
 fn sky(rd: vec3<f32>) -> vec3<f32> {
diff --git a/src/vk/rt_query.wgsl b/src/vk/rt_query.wgsl
new file mode 100644
index 0000000..7960857
--- /dev/null
+++ b/src/vk/rt_query.wgsl
@@ -0,0 +1,19 @@
+// rt_query.wgsl — tier-2 intersect_scene: hardware ray queries against a
+// driver-built TLAS (VK_KHR_ray_query), engaging RT cores where present.
+// Concatenated after rt_common.wgsl at pipeline creation; compiled with
+// naga's RAY_QUERY capability to SPIR-V 1.4.
+
+@group(0) @binding(1) var tlas: acceleration_structure;
+
+fn intersect_scene(ro: vec3<f32>, rd: vec3<f32>) -> HitInfo {
+    var rq: ray_query;
+    // Geometry is marked opaque at BLAS build, no cull flags: two-sided hits
+    // like the BVH tier. tmin matches the tier-1 epsilon.
+    rayQueryInitialize(&rq, tlas, RayDesc(0x0u, 0xFFu, 1e-4, 1e30, ro, rd));
+    while (rayQueryProceed(&rq)) {}
+    let hit = rayQueryGetCommittedIntersection(&rq);
+    if hit.kind == RAY_QUERY_INTERSECTION_TRIANGLE {
+        return HitInfo(hit.t, hit.primitive_index);
+    }
+    return HitInfo(1e30, 0u);
+}