Skip to main content

qualia_core_db/render/gpu/
mod.rs

1//! Cross-platform WebGPU viewport for the Qualia renderer SDK.
2//!
3//! Phenomenal viewport: projector (depth write) → ambient → optional T2 Kawase bloom.
4
5use crate::gpu_context::{
6    ambient_draw_instances, global_vram_ledger, universe_orchestrator, ComputeUniverse,
7    OperationalMode,
8};
9use crate::render::camera::CameraState;
10use crate::render::navigation::PICK_SENTINEL;
11use crate::render::pga::{motor_to_mat4_col, Motor};
12use crate::render::physics::{Aabb, Admission, Joint};
13use crate::render::standpoint::spectator_default;
14use crate::render::telemetry::{
15    AmbientUniforms, ObserverStandpoint, ParticleInstance, SystemTelemetry,
16};
17use crate::shaders::viewport::{AMBIENT_WGSL, BLOOM_WGSL, MESH_WGSL, PROJECTOR_WGSL};
18use crate::tensor::buffer_export::{
19    read_tensor_at, tensor_node_count, TENSOR_HEADER_BYTES, TENSOR_STRIDE,
20};
21
22use std::sync::Arc;
23use wgpu::util::DeviceExt;
24
25/// Static ambient SSBO capacity — draw count is throttled per `VramLedger` mode.
26const MAX_AMBIENT_INSTANCES: usize = 50_000;
27const HDR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
28const BLOOM_THRESHOLD: f32 = 1.0;
29const BLOOM_INTENSITY: f32 = 1.15;
30const BLOOM_STRENGTH: f32 = 0.85;
31const BLOOM_EXPOSURE: f32 = 1.05;
32const KAWASE_OFFSETS: [f32; 4] = [1.0, 2.0, 4.0, 8.0];
33
34#[repr(C)]
35#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
36struct BloomParamsGpu {
37    threshold: f32,
38    intensity: f32,
39    offset: f32,
40    _pad: f32,
41}
42
43#[repr(C)]
44#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
45struct CompositeParamsGpu {
46    exposure: f32,
47    bloom_strength: f32,
48    _pad0: f32,
49    _pad1: f32,
50}
51
52#[repr(C)]
53#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
54struct BloomUniformBlock {
55    bloom: BloomParamsGpu,
56    composite: CompositeParamsGpu,
57}
58
59/// HDR scene target + half-res Kawase ping-pong (allocated only in `OperationalMode::Full`).
60struct BloomChain {
61    hdr_texture: wgpu::Texture,
62    hdr_view: wgpu::TextureView,
63    blur_a: wgpu::Texture,
64    blur_a_view: wgpu::TextureView,
65    blur_b: wgpu::Texture,
66    blur_b_view: wgpu::TextureView,
67    dummy_view: wgpu::TextureView,
68    sampler: wgpu::Sampler,
69    uniform_buf: wgpu::Buffer,
70    bind_layout: wgpu::BindGroupLayout,
71    extract_pipeline: wgpu::RenderPipeline,
72    kawase_pipeline: wgpu::RenderPipeline,
73    composite_pipeline: wgpu::RenderPipeline,
74    half_width: u32,
75    half_height: u32,
76    vram_bytes: u64,
77}
78
79impl BloomChain {
80    /// HDR scene target extent in pixels.
81    pub(super) fn hdr_extent(&self) -> (u32, u32) {
82        (self.hdr_texture.width(), self.hdr_texture.height())
83    }
84
85    /// Half-resolution Kawase ping-pong extent.
86    pub(super) fn blur_extent(&self) -> (u32, u32) {
87        (self.half_width, self.half_height)
88    }
89
90    /// Keep texture handles alive for resize validation / VRAM accounting.
91    pub(super) fn texture_handles(&self) -> (&wgpu::Texture, &wgpu::Texture, &wgpu::Texture) {
92        (&self.hdr_texture, &self.blur_a, &self.blur_b)
93    }
94}
95
96/// WebGPU phenomenal viewport — tensor projector + ambient particles.
97/// GPU buffers for an imported triangle mesh (Phase 1.2). Positions are model-space `f32x3`
98/// (already centred + scaled to the orbit frame by the caller); `index_count` is `triangles * 3`.
99struct MeshGpu {
100    vertex_buf: wgpu::Buffer,
101    color_buf: wgpu::Buffer,
102    index_buf: wgpu::Buffer,
103    index_count: u32,
104}
105
106pub struct PortalGpu {
107    device: Arc<wgpu::Device>,
108    queue: Arc<wgpu::Queue>,
109    surface: Option<wgpu::Surface<'static>>,
110    config: Option<wgpu::SurfaceConfiguration>,
111    offscreen_texture: Option<wgpu::Texture>,
112    readback_buf: Option<wgpu::Buffer>,
113    readback_bytes_per_row: u32,
114    color_format: wgpu::TextureFormat,
115    depth_texture: wgpu::Texture,
116    depth_view: wgpu::TextureView,
117    picking_texture: wgpu::Texture,
118    picking_view: wgpu::TextureView,
119    picking_pipeline: wgpu::RenderPipeline,
120    pick_staging_buf: wgpu::Buffer,
121    pending_pick: Option<(u32, u32)>,
122    pick_copy_submitted: bool,
123    pick_result: Option<u32>,
124    ambient_pipeline: wgpu::RenderPipeline,
125    projector_pipeline: wgpu::RenderPipeline,
126    ambient_pipeline_hdr: Option<wgpu::RenderPipeline>,
127    projector_pipeline_hdr: Option<wgpu::RenderPipeline>,
128    mesh_pipeline: wgpu::RenderPipeline,
129    mesh_pipeline_hdr: Option<wgpu::RenderPipeline>,
130    mesh: Option<MeshGpu>,
131    model_buf: wgpu::Buffer,
132    mesh_model_bind: wgpu::BindGroup,
133    artefact_joint: Option<Joint>,
134    /// Sim-time at which the current joint was engaged; the joint is driven by *elapsed* time
135    /// (`time − artefact_t0`), not absolute sim-time, so a slide/spin always starts from rest when
136    /// armed (set lazily on the first frame after `set_artefact_joint`).
137    artefact_t0: Option<f32>,
138    mesh_base_aabb: Option<Aabb>,
139    artefact_world: Option<Aabb>,
140    last_admitted: Motor,
141    last_refused: bool,
142    bloom: Option<BloomChain>,
143    ambient_bind_group_layout: wgpu::BindGroupLayout,
144    ambient_bind_group: wgpu::BindGroup,
145    projector_camera_layout: wgpu::BindGroupLayout,
146    projector_tensor_layout: wgpu::BindGroupLayout,
147    projector_camera_bind: wgpu::BindGroup,
148    projector_tensor_bind: Option<wgpu::BindGroup>,
149    uniform_buf: wgpu::Buffer,
150    telemetry_buf: wgpu::Buffer,
151    camera_buf: wgpu::Buffer,
152    observer_buf: wgpu::Buffer,
153    camera: CameraState,
154    observer: ObserverStandpoint,
155    particle_buf: wgpu::Buffer,
156    tensor_raw_buf: Option<wgpu::Buffer>,
157    tensor_node_count: u32,
158    particle_count: u32,
159    /// Whether the ambient particle field is drawn. **Off by default** — the field is a random
160    /// decorative cloud (`generate_particles`, `epistemic_q = 0`) unless a Tensor10D is uploaded,
161    /// which sets this true because the particles then ARE the epistemic nodes. The mixer's "ambient"
162    /// channel toggles it explicitly.
163    ambient_enabled: bool,
164    width: u32,
165    height: u32,
166}
167
168impl PortalGpu {
169    /// Build a native offscreen renderer on QualiaDB's process-wide shared GPU device.
170    ///
171    /// The output target is linear `Rgba8Unorm`. Call [`Self::render`] and then
172    /// [`Self::read_rgba8_into`] to retrieve tightly packed pixels into a caller-owned buffer.
173    #[cfg(not(target_arch = "wasm32"))]
174    pub fn new_offscreen(width: u32, height: u32, particle_cap: usize) -> Result<Self, String> {
175        let shared = crate::gpu_context::shared_gpu();
176        pollster::block_on(Self::from_device(
177            Arc::new(shared.device.clone()),
178            Arc::new(shared.queue.clone()),
179            width.max(1),
180            height.max(1),
181            wgpu::TextureFormat::Rgba8Unorm,
182            None,
183            None,
184            particle_cap,
185        ))
186    }
187
188    /// Build a native **surface** renderer that draws directly to a window's GPU swapchain.
189    ///
190    /// This is the native desktop path — no PNG round-trip, no webview `<img>`. The surface
191    /// is created from a raw window handle (HWND on Windows) and frames are presented directly
192    /// to the OS swapchain.
193    ///
194    /// The surface format is chosen from the adapter's capabilities (sRGB preferred).
195    /// Call [`Self::render`] to draw a frame; the swapchain present is automatic.
196    #[cfg(all(not(target_arch = "wasm32"), feature = "gpu-runtime"))]
197    pub fn new_surface(
198        hwnd: isize,
199        width: u32,
200        height: u32,
201        particle_cap: usize,
202    ) -> Result<Self, String> {
203        use raw_window_handle::{
204            RawDisplayHandle, RawWindowHandle, Win32WindowHandle, WindowsDisplayHandle,
205        };
206
207        // Create a DEDICATED instance/adapter/device for the surface renderer.
208        // The shared GPU context is optimised for compute (LLM inference) and its
209        // adapter may not support presentation to an HWND (e.g. Vulkan without a
210        // VkSurfaceKHR, or a compute-only adapter). A dedicated instance ensures
211        // the surface, adapter, and device are all from the same wgpu instance and
212        // the adapter is picked with surface compatibility.
213        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
214        desc.backends = wgpu::Backends::all();
215        let instance = wgpu::Instance::new(desc);
216
217        let win32_handle =
218            Win32WindowHandle::new(std::num::NonZeroIsize::new(hwnd).ok_or("invalid HWND (zero)")?);
219        let raw_window = RawWindowHandle::Win32(win32_handle);
220        let raw_display = RawDisplayHandle::Windows(WindowsDisplayHandle::new());
221
222        let surface = unsafe {
223            instance
224                .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle {
225                    raw_display_handle: Some(raw_display),
226                    raw_window_handle: raw_window,
227                })
228                .map_err(|e| format!("create_surface from HWND: {e:?}"))?
229        };
230
231        // Request an adapter that supports the surface
232        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
233            power_preference: wgpu::PowerPreference::HighPerformance,
234            compatible_surface: Some(&surface),
235            ..Default::default()
236        }))
237        .map_err(|e| format!("Failed to find wgpu adapter for surface: {e}"))?;
238
239        let caps = surface.get_capabilities(&adapter);
240        if caps.formats.is_empty() {
241            return Err(format!(
242                "Surface supports no formats — adapter backend {:?} may not support presentation to HWND",
243                adapter.get_info().backend
244            ));
245        }
246        let format = caps
247            .formats
248            .iter()
249            .copied()
250            .find(|f| f.is_srgb())
251            .unwrap_or(caps.formats[0]);
252
253        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
254            label: Some("Webizen GPU Surface"),
255            required_features: wgpu::Features::empty(),
256            required_limits: wgpu::Limits::downlevel_defaults(),
257            memory_hints: wgpu::MemoryHints::default(),
258            ..Default::default()
259        }))
260        .map_err(|e| format!("Failed to request device for surface: {e}"))?;
261
262        let device = Arc::new(device);
263        let queue = Arc::new(queue);
264
265        let config = wgpu::SurfaceConfiguration {
266            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
267            format,
268            width: width.max(1),
269            height: height.max(1),
270            present_mode: wgpu::PresentMode::Fifo,
271            alpha_mode: caps
272                .alpha_modes
273                .first()
274                .copied()
275                .unwrap_or(wgpu::CompositeAlphaMode::Auto),
276            view_formats: vec![],
277            desired_maximum_frame_latency: 2,
278            // wgpu 30: surfaces declare their colour space; Auto preserves the
279            // pre-30 (implicit sRGB/linear-by-format) behaviour.
280            color_space: wgpu::SurfaceColorSpace::Auto,
281        };
282
283        surface.configure(&device, &config);
284
285        pollster::block_on(Self::from_device(
286            device,
287            queue,
288            width.max(1),
289            height.max(1),
290            format,
291            Some(surface),
292            Some(config),
293            particle_cap,
294        ))
295    }
296
297    /// Async WebGPU init — awaits `request_adapter` / `request_device` (the browser main thread
298    /// cannot block). Native callers use the `try_new` wrapper above.
299    #[cfg(all(target_arch = "wasm32", feature = "portal"))]
300    pub async fn try_new_async(
301        canvas: &web_sys::HtmlCanvasElement,
302        particle_cap: usize,
303    ) -> Result<Self, String> {
304        let width = canvas.width().max(1);
305        let height = canvas.height().max(1);
306
307        let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle();
308        instance_desc.backends = wgpu::Backends::BROWSER_WEBGPU;
309        let instance = wgpu::Instance::new(instance_desc);
310
311        let surface = instance
312            .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
313            .map_err(|e| format!("surface: {e}"))?;
314
315        let adapter = instance
316            .request_adapter(&wgpu::RequestAdapterOptions {
317                power_preference: wgpu::PowerPreference::LowPower,
318                compatible_surface: Some(&surface),
319                // `..Default::default()` covers force_fallback_adapter (false) and
320                // wgpu 30's apply_limit_buckets, and stays robust to future fields.
321                ..Default::default()
322            })
323            .await
324            .map_err(|e| format!("no WebGPU adapter: {e}"))?;
325
326        let (device, queue) = adapter
327            .request_device(&wgpu::DeviceDescriptor {
328                label: Some("qualia-portal-gpu"),
329                required_features: wgpu::Features::empty(),
330                // Request exactly the adapter's advertised limits. `Limits::default()`
331                // under wgpu 30 asks for desktop-tier limits that a browser WebGPU
332                // adapter does not grant, so `request_device` would fail device
333                // validation. `adapter.limits()` never over-requests and still preserves
334                // the non-zero storage-buffer limits the portal pipelines need (unlike
335                // `downlevel_webgl2_defaults`, which zeroes them and blacks out the view).
336                required_limits: adapter.limits(),
337                ..Default::default()
338            })
339            .await
340            .map_err(|e| format!("device: {e}"))?;
341
342        let caps = surface.get_capabilities(&adapter);
343        let format = caps
344            .formats
345            .iter()
346            .copied()
347            .find(|f| f.is_srgb())
348            .unwrap_or(caps.formats[0]);
349
350        let config = wgpu::SurfaceConfiguration {
351            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
352            format,
353            width,
354            height,
355            present_mode: wgpu::PresentMode::Fifo,
356            alpha_mode: caps.alpha_modes[0],
357            view_formats: vec![],
358            desired_maximum_frame_latency: 2,
359            // wgpu 30: Auto preserves pre-30 colour-space behaviour.
360            color_space: wgpu::SurfaceColorSpace::Auto,
361        };
362        surface.configure(&device, &config);
363
364        Self::from_device(
365            Arc::new(device),
366            Arc::new(queue),
367            width,
368            height,
369            format,
370            Some(surface),
371            Some(config),
372            particle_cap,
373        )
374        .await
375    }
376
377    async fn from_device(
378        device: Arc<wgpu::Device>,
379        queue: Arc<wgpu::Queue>,
380        width: u32,
381        height: u32,
382        format: wgpu::TextureFormat,
383        surface: Option<wgpu::Surface<'static>>,
384        config: Option<wgpu::SurfaceConfiguration>,
385        particle_cap: usize,
386    ) -> Result<Self, String> {
387        let particle_count = particle_cap.clamp(256, MAX_AMBIENT_INSTANCES);
388
389        // Capture deferred pipeline/shader creation errors on both Dawn and native backends.
390        let error_scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
391
392        let (depth_texture, depth_view) = create_depth_texture(&device, width, height);
393        let (picking_texture, picking_view) = create_picking_texture(&device, width, height);
394        let offscreen_texture = if surface.is_none() {
395            Some(create_offscreen_texture(&device, format, width, height))
396        } else {
397            None
398        };
399        let readback_bytes_per_row = padded_bytes_per_row(width);
400        let readback_buf = if surface.is_none() {
401            Some(create_readback_buffer(
402                &device,
403                readback_bytes_per_row,
404                height,
405            ))
406        } else {
407            None
408        };
409
410        let particles = generate_particles(particle_count);
411        let particle_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
412            label: Some("portal-particles"),
413            contents: bytemuck::cast_slice(&particles),
414            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
415        });
416
417        let uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
418            label: Some("portal-ambient-uniforms"),
419            contents: bytemuck::bytes_of(&AmbientUniforms {
420                time: 0.0,
421                view_width: width as f32,
422                view_height: height as f32,
423                _padding: 0.0,
424            }),
425            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
426        });
427
428        let telemetry_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
429            label: Some("portal-telemetry"),
430            contents: bytemuck::bytes_of(&SystemTelemetry::default()),
431            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
432        });
433
434        let camera = CameraState::default();
435        let aspect = width as f32 / height.max(1) as f32;
436        let camera_uniform = camera.to_uniform(aspect, false);
437        let camera_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
438            label: Some("portal-camera"),
439            contents: bytemuck::bytes_of(&camera_uniform),
440            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
441        });
442
443        let session_nonce = crate::render::standpoint::generate_session_nonce();
444        let observer = spectator_default(session_nonce);
445        let observer_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
446            label: Some("portal-observer"),
447            contents: bytemuck::bytes_of(&observer),
448            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
449        });
450
451        let ambient_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
452            label: Some("portal-ambient"),
453            source: wgpu::ShaderSource::Wgsl(AMBIENT_WGSL.into()),
454        });
455
456        let projector_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
457            label: Some("portal-projector"),
458            source: wgpu::ShaderSource::Wgsl(PROJECTOR_WGSL.into()),
459        });
460
461        let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
462            label: Some("portal-mesh"),
463            source: wgpu::ShaderSource::Wgsl(MESH_WGSL.into()),
464        });
465
466        let ambient_bind_group_layout =
467            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
468                label: Some("portal-ambient-layout"),
469                entries: &ambient_bind_entries(),
470            });
471
472        let ambient_bind_group = make_ambient_bind_group(
473            &device,
474            &ambient_bind_group_layout,
475            &uniform_buf,
476            &telemetry_buf,
477            &camera_buf,
478            &observer_buf,
479            &particle_buf,
480        );
481
482        let projector_camera_layout =
483            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
484                label: Some("portal-projector-camera-layout"),
485                entries: &[
486                    uniform_128_bind_entry(0, wgpu::ShaderStages::VERTEX),
487                    uniform_128_bind_entry(1, wgpu::ShaderStages::VERTEX_FRAGMENT),
488                ],
489            });
490
491        let projector_tensor_layout =
492            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
493                label: Some("portal-projector-tensor-layout"),
494                entries: &[tensor_storage_bind_entry()],
495            });
496
497        let projector_camera_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
498            label: Some("portal-projector-camera-bind"),
499            layout: &projector_camera_layout,
500            entries: &[
501                wgpu::BindGroupEntry {
502                    binding: 0,
503                    resource: camera_buf.as_entire_binding(),
504                },
505                wgpu::BindGroupEntry {
506                    binding: 1,
507                    resource: observer_buf.as_entire_binding(),
508                },
509            ],
510        });
511
512        let depth_state = depth_stencil_state(wgpu::TextureFormat::Depth32Float);
513
514        let ambient_pipeline_layout =
515            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
516                label: Some("portal-ambient-pipeline-layout"),
517                bind_group_layouts: &[Some(&ambient_bind_group_layout)],
518                immediate_size: 0,
519            });
520
521        let projector_pipeline_layout =
522            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
523                label: Some("portal-projector-pipeline-layout"),
524                bind_group_layouts: &[
525                    Some(&projector_camera_layout),
526                    Some(&projector_tensor_layout),
527                ],
528                immediate_size: 0,
529            });
530
531        let ambient_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
532            label: Some("portal-ambient-pipeline"),
533            layout: Some(&ambient_pipeline_layout),
534            vertex: wgpu::VertexState {
535                module: &ambient_shader,
536                entry_point: Some("vertex_main"),
537                compilation_options: Default::default(),
538                buffers: &[],
539            },
540            fragment: Some(wgpu::FragmentState {
541                module: &ambient_shader,
542                entry_point: Some("fragment_main"),
543                compilation_options: Default::default(),
544                targets: &[Some(color_target_state(format))],
545            }),
546            primitive: wgpu::PrimitiveState {
547                topology: wgpu::PrimitiveTopology::TriangleList,
548                ..Default::default()
549            },
550            depth_stencil: Some(depth_stencil_state_read_only()),
551            multisample: wgpu::MultisampleState::default(),
552            multiview_mask: None,
553            cache: None,
554        });
555
556        let projector_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
557            label: Some("portal-projector-pipeline"),
558            layout: Some(&projector_pipeline_layout),
559            vertex: wgpu::VertexState {
560                module: &projector_shader,
561                entry_point: Some("vertex_main"),
562                compilation_options: Default::default(),
563                buffers: &[],
564            },
565            fragment: Some(wgpu::FragmentState {
566                module: &projector_shader,
567                entry_point: Some("fragment_main"),
568                compilation_options: Default::default(),
569                targets: &[Some(color_target_state(format))],
570            }),
571            primitive: wgpu::PrimitiveState {
572                topology: wgpu::PrimitiveTopology::TriangleList,
573                ..Default::default()
574            },
575            depth_stencil: Some(depth_state.clone()),
576            multisample: wgpu::MultisampleState::default(),
577            multiview_mask: None,
578            cache: None,
579        });
580
581        // Per-artefact model transform (Phase 2 kinematic joint), group 1 of the mesh pipeline.
582        const IDENTITY_MAT4: [[f32; 4]; 4] = [
583            [1.0, 0.0, 0.0, 0.0],
584            [0.0, 1.0, 0.0, 0.0],
585            [0.0, 0.0, 1.0, 0.0],
586            [0.0, 0.0, 0.0, 1.0],
587        ];
588        let model_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
589            label: Some("portal-mesh-model"),
590            contents: bytemuck::cast_slice(&IDENTITY_MAT4),
591            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
592        });
593        let mesh_model_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
594            label: Some("portal-mesh-model-layout"),
595            entries: &[wgpu::BindGroupLayoutEntry {
596                binding: 0,
597                visibility: wgpu::ShaderStages::VERTEX,
598                ty: wgpu::BindingType::Buffer {
599                    ty: wgpu::BufferBindingType::Uniform,
600                    has_dynamic_offset: false,
601                    min_binding_size: std::num::NonZeroU64::new(64),
602                },
603                count: None,
604            }],
605        });
606        let mesh_model_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
607            label: Some("portal-mesh-model-bind"),
608            layout: &mesh_model_layout,
609            entries: &[wgpu::BindGroupEntry {
610                binding: 0,
611                resource: model_buf.as_entire_binding(),
612            }],
613        });
614
615        // Triangle-mesh pipeline (Phase 1.2). Reuses the projector camera bind layout (mesh shader
616        // declares only camera@0, a valid subset); one f32x3 vertex buffer at slot 0; cull disabled
617        // (imported meshes carry inconsistent winding). HDR variant built in the bloom block below.
618        let mesh_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
619            label: Some("portal-mesh-pipeline-layout"),
620            bind_group_layouts: &[Some(&projector_camera_layout), Some(&mesh_model_layout)],
621            immediate_size: 0,
622        });
623        let mesh_vertex_layout = wgpu::VertexBufferLayout {
624            array_stride: 12,
625            step_mode: wgpu::VertexStepMode::Vertex,
626            attributes: &[wgpu::VertexAttribute {
627                format: wgpu::VertexFormat::Float32x3,
628                offset: 0,
629                shader_location: 0,
630            }],
631        };
632        let mesh_color_layout = wgpu::VertexBufferLayout {
633            array_stride: 16,
634            step_mode: wgpu::VertexStepMode::Vertex,
635            attributes: &[wgpu::VertexAttribute {
636                format: wgpu::VertexFormat::Float32x4,
637                offset: 0,
638                shader_location: 1,
639            }],
640        };
641        let mesh_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
642            label: Some("portal-mesh-pipeline"),
643            layout: Some(&mesh_pipeline_layout),
644            vertex: wgpu::VertexState {
645                module: &mesh_shader,
646                entry_point: Some("vertex_main"),
647                compilation_options: Default::default(),
648                buffers: &[
649                    Some(mesh_vertex_layout.clone()),
650                    Some(mesh_color_layout.clone()),
651                ],
652            },
653            fragment: Some(wgpu::FragmentState {
654                module: &mesh_shader,
655                entry_point: Some("fragment_main"),
656                compilation_options: Default::default(),
657                targets: &[Some(color_target_state(format))],
658            }),
659            primitive: wgpu::PrimitiveState {
660                topology: wgpu::PrimitiveTopology::TriangleList,
661                cull_mode: None,
662                ..Default::default()
663            },
664            depth_stencil: Some(depth_state.clone()),
665            multisample: wgpu::MultisampleState::default(),
666            multiview_mask: None,
667            cache: None,
668        });
669
670        let picking_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
671            label: Some("portal-picking-pipeline"),
672            layout: Some(&projector_pipeline_layout),
673            vertex: wgpu::VertexState {
674                module: &projector_shader,
675                entry_point: Some("vertex_main"),
676                compilation_options: Default::default(),
677                buffers: &[],
678            },
679            fragment: Some(wgpu::FragmentState {
680                module: &projector_shader,
681                entry_point: Some("picking_fragment_main"),
682                compilation_options: Default::default(),
683                targets: &[Some(picking_color_target_state())],
684            }),
685            primitive: wgpu::PrimitiveState {
686                topology: wgpu::PrimitiveTopology::TriangleList,
687                ..Default::default()
688            },
689            depth_stencil: Some(depth_state.clone()),
690            multisample: wgpu::MultisampleState::default(),
691            multiview_mask: None,
692            cache: None,
693        });
694
695        let pick_staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
696            label: Some("portal-pick-staging"),
697            size: 4,
698            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
699            mapped_at_creation: false,
700        });
701
702        let bloom_wanted = portal_bloom_enabled() && probe_hdr_format(&device);
703        let (ambient_pipeline_hdr, projector_pipeline_hdr, mesh_pipeline_hdr, bloom) =
704            if bloom_wanted {
705                let ambient_hdr = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
706                    label: Some("portal-ambient-hdr"),
707                    layout: Some(&ambient_pipeline_layout),
708                    vertex: wgpu::VertexState {
709                        module: &ambient_shader,
710                        entry_point: Some("vertex_main"),
711                        compilation_options: Default::default(),
712                        buffers: &[],
713                    },
714                    fragment: Some(wgpu::FragmentState {
715                        module: &ambient_shader,
716                        entry_point: Some("fragment_main"),
717                        compilation_options: Default::default(),
718                        targets: &[Some(hdr_color_target_state())],
719                    }),
720                    primitive: wgpu::PrimitiveState {
721                        topology: wgpu::PrimitiveTopology::TriangleList,
722                        ..Default::default()
723                    },
724                    depth_stencil: Some(depth_stencil_state_read_only()),
725                    multisample: wgpu::MultisampleState::default(),
726                    multiview_mask: None,
727                    cache: None,
728                });
729                let projector_hdr =
730                    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
731                        label: Some("portal-projector-hdr"),
732                        layout: Some(&projector_pipeline_layout),
733                        vertex: wgpu::VertexState {
734                            module: &projector_shader,
735                            entry_point: Some("vertex_main"),
736                            compilation_options: Default::default(),
737                            buffers: &[],
738                        },
739                        fragment: Some(wgpu::FragmentState {
740                            module: &projector_shader,
741                            entry_point: Some("fragment_main"),
742                            compilation_options: Default::default(),
743                            targets: &[Some(hdr_color_target_state())],
744                        }),
745                        primitive: wgpu::PrimitiveState {
746                            topology: wgpu::PrimitiveTopology::TriangleList,
747                            ..Default::default()
748                        },
749                        depth_stencil: Some(depth_state.clone()),
750                        multisample: wgpu::MultisampleState::default(),
751                        multiview_mask: None,
752                        cache: None,
753                    });
754                let mesh_hdr = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
755                    label: Some("portal-mesh-hdr"),
756                    layout: Some(&mesh_pipeline_layout),
757                    vertex: wgpu::VertexState {
758                        module: &mesh_shader,
759                        entry_point: Some("vertex_main"),
760                        compilation_options: Default::default(),
761                        buffers: &[
762                            Some(mesh_vertex_layout.clone()),
763                            Some(mesh_color_layout.clone()),
764                        ],
765                    },
766                    fragment: Some(wgpu::FragmentState {
767                        module: &mesh_shader,
768                        entry_point: Some("fragment_main"),
769                        compilation_options: Default::default(),
770                        targets: &[Some(hdr_color_target_state())],
771                    }),
772                    primitive: wgpu::PrimitiveState {
773                        topology: wgpu::PrimitiveTopology::TriangleList,
774                        cull_mode: None,
775                        ..Default::default()
776                    },
777                    depth_stencil: Some(depth_state.clone()),
778                    multisample: wgpu::MultisampleState::default(),
779                    multiview_mask: None,
780                    cache: None,
781                });
782                let bloom = create_bloom_chain(&device, width, height, format);
783                (
784                    Some(ambient_hdr),
785                    Some(projector_hdr),
786                    Some(mesh_hdr),
787                    bloom,
788                )
789            } else {
790                (None, None, None, None)
791            };
792
793        let mut render_bytes = (particle_count * std::mem::size_of::<ParticleInstance>()) as u64;
794        if let Some(ref chain) = bloom {
795            render_bytes += chain.vram_bytes;
796        }
797        global_vram_ledger().record_render(render_bytes);
798
799        // Surface otherwise-silent deferred pipeline/shader creation errors. Dawn (WebGPU) is far
800        // stricter than the native backends, so a pipeline that builds on desktop can be invalid in
801        // the browser and silently render nothing; log it instead of leaving a black viewport.
802        //
803        // On wasm we CANNOT await this pop under wgpu 30.0.0: `GPUDevice.popErrorScope()` resolves
804        // to `GPUError | null`, and on the (common) no-error path it returns JS `null`. wgpu's
805        // `future_pop_error_scope` feeds that through `JsOption::into_option()`, which — since
806        // wasm-bindgen 0.2.123 — treats `null` as a *present* value (only `undefined` is absent).
807        // So a null (no error) becomes `Some(null)`, wgpu calls `Error::from_js(null)`, and
808        // webgpu.rs:85 panics `"Unexpected error"`, aborting the module before the canvas ever
809        // renders. Dropping the guard still pops the scope (discarding any captured error) but
810        // never polls that buggy future, so it cannot panic. Tracked in
811        // docs/WGPU_UPSTREAM_TRACKING.md for a proper soft-fork of `future_pop_error_scope`.
812        #[cfg(not(target_arch = "wasm32"))]
813        {
814            let scope_err = error_scope.pop().await;
815            if let Some(err) = scope_err {
816                return Err(format!("renderer pipeline/shader creation failed: {err}"));
817            }
818        }
819        #[cfg(target_arch = "wasm32")]
820        drop(error_scope);
821
822        Ok(Self {
823            device,
824            queue,
825            surface,
826            config,
827            offscreen_texture,
828            readback_buf,
829            readback_bytes_per_row,
830            color_format: format,
831            depth_texture,
832            depth_view,
833            picking_texture,
834            picking_view,
835            picking_pipeline,
836            pick_staging_buf,
837            pending_pick: None,
838            pick_copy_submitted: false,
839            pick_result: None,
840            ambient_pipeline,
841            projector_pipeline,
842            ambient_pipeline_hdr,
843            projector_pipeline_hdr,
844            mesh_pipeline,
845            mesh_pipeline_hdr,
846            mesh: None,
847            model_buf,
848            mesh_model_bind,
849            artefact_joint: None,
850            artefact_t0: None,
851            mesh_base_aabb: None,
852            artefact_world: None,
853            last_admitted: Motor::identity(),
854            last_refused: false,
855            bloom,
856            ambient_bind_group_layout,
857            ambient_bind_group,
858            projector_camera_layout,
859            projector_tensor_layout,
860            projector_camera_bind,
861            projector_tensor_bind: None,
862            uniform_buf,
863            telemetry_buf,
864            camera_buf,
865            observer_buf,
866            camera,
867            observer,
868            particle_buf,
869            tensor_raw_buf: None,
870            tensor_node_count: 0,
871            particle_count: particle_count as u32,
872            ambient_enabled: false,
873            width,
874            height,
875        })
876    }
877
878    /// Enable/disable the ambient particle field draw. Off by default (a plain mesh/anatomy view has
879    /// no use for the decorative random cloud); a Tensor10D upload turns it on since the particles then
880    /// encode epistemic nodes. The mixer's "ambient" channel drives this.
881    pub fn set_ambient_enabled(&mut self, on: bool) {
882        self.ambient_enabled = on;
883    }
884
885    pub fn upload_tensor_buffer(&mut self, bytes: &[u8]) -> Result<u32, String> {
886        let (header, _) =
887            crate::tensor::buffer_export::parse_header(bytes).map_err(|e| e.to_string())?;
888        let count = header.node_count;
889        if count == 0 {
890            return Ok(0);
891        }
892
893        let particles = particles_from_tensor(bytes, MAX_AMBIENT_INSTANCES)?;
894        let instance_count = particles.len() as u32;
895
896        let particle_buf = self
897            .device
898            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
899                label: Some("portal-tensor-particles"),
900                contents: bytemuck::cast_slice(&particles),
901                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
902            });
903
904        // Upload the SOA *body* only (skip the 32-byte header). WebGPU requires storage-buffer
905        // binding offsets to be a multiple of minStorageBufferOffsetAlignment (256), so we cannot
906        // bind at offset 32 the way native backends allow — start the buffer at the first record
907        // and bind at offset 0.
908        let body = bytes
909            .get(TENSOR_HEADER_BYTES..)
910            .ok_or_else(|| "tensor buffer shorter than header".to_string())?;
911        let tensor_raw_buf = self
912            .device
913            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
914                label: Some("portal-tensor-raw-soa"),
915                contents: body,
916                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
917            });
918
919        self.ambient_bind_group = make_ambient_bind_group(
920            &self.device,
921            &self.ambient_bind_group_layout,
922            &self.uniform_buf,
923            &self.telemetry_buf,
924            &self.camera_buf,
925            &self.observer_buf,
926            &particle_buf,
927        );
928
929        self.projector_camera_bind = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
930            label: Some("portal-projector-camera-bind"),
931            layout: &self.projector_camera_layout,
932            entries: &[
933                wgpu::BindGroupEntry {
934                    binding: 0,
935                    resource: self.camera_buf.as_entire_binding(),
936                },
937                wgpu::BindGroupEntry {
938                    binding: 1,
939                    resource: self.observer_buf.as_entire_binding(),
940                },
941            ],
942        });
943
944        self.projector_tensor_bind = Some(make_projector_tensor_bind_group(
945            &self.device,
946            &self.projector_tensor_layout,
947            &tensor_raw_buf,
948            count,
949        )?);
950
951        let particle_bytes = (particles.len() * std::mem::size_of::<ParticleInstance>()) as u64;
952        global_vram_ledger().record_render(particle_bytes);
953        global_vram_ledger().record_tensor(bytes.len() as u64);
954
955        self.particle_buf = particle_buf;
956        self.tensor_raw_buf = Some(tensor_raw_buf);
957        self.tensor_node_count = count;
958        self.particle_count = instance_count.max(1);
959        // The particle field now carries real tensor nodes (not the decorative random cloud) — show it.
960        self.ambient_enabled = true;
961
962        Ok(count)
963    }
964
965    pub fn tensor_node_count(&self) -> u32 {
966        self.tensor_node_count
967    }
968
969    /// Drive the loaded mesh by a kinematic joint (Phase 2). `None` freezes it at identity.
970    pub fn set_artefact_joint(&mut self, joint: Option<Joint>) {
971        self.artefact_joint = joint;
972        self.artefact_t0 = None; // re-engage from rest: the slide/spin starts at elapsed t = 0
973        self.last_admitted = Motor::identity();
974        self.last_refused = false;
975    }
976
977    /// Constrain the artefact to a world bound; a joint pose that would leave it is refused
978    /// (the artefact holds at the last admitted pose). `None` = unconstrained.
979    pub fn set_artefact_world(&mut self, world: Option<Aabb>) {
980        self.artefact_world = world;
981    }
982
983    /// Whether last frame's proposed joint pose was deterministically refused (clamped at the bound).
984    pub fn artefact_refused(&self) -> bool {
985        self.last_refused
986    }
987
988    /// Resolve this frame's per-artefact model transform: the joint pose at `time`, gated through
989    /// the admission policy (refuse out-of-world → hold the last admitted pose), then write it.
990    fn update_model(&mut self, time: f32) {
991        let proposed = match self.artefact_joint {
992            // Drive by *elapsed* time since the joint was engaged, not absolute sim-time, so a slide
993            // always starts from rest when armed (the t0 is latched on this first post-arm frame).
994            Some(j) => {
995                let t0 = *self.artefact_t0.get_or_insert(time);
996                j.motor_at(time - t0)
997            }
998            None => Motor::identity(),
999        };
1000        let motor = match (self.mesh_base_aabb, self.artefact_world) {
1001            (Some(base), Some(world)) => {
1002                match Admission::new(0.0, Some(world)).admit(&base, proposed, [1.0, 1.0, 1.0]) {
1003                    Ok(_) => {
1004                        self.last_refused = false;
1005                        self.last_admitted = proposed;
1006                        proposed
1007                    }
1008                    Err(_) => {
1009                        self.last_refused = true;
1010                        self.last_admitted // deterministic refusal: hold at the boundary
1011                    }
1012                }
1013            }
1014            _ => {
1015                self.last_refused = false;
1016                proposed
1017            }
1018        };
1019        let model = motor_to_mat4_col(motor);
1020        self.queue
1021            .write_buffer(&self.model_buf, 0, bytemuck::cast_slice(&model));
1022    }
1023
1024    pub fn has_tensor_buffer(&self) -> bool {
1025        self.tensor_raw_buf.is_some()
1026    }
1027
1028    /// Upload an imported triangle mesh (Phase 1.2). `positions` are model-space `f32x3` (the caller
1029    /// centres + scales them to the orbit frame); `indices` is a flat triangle list (`tris * 3`).
1030    /// Returns the triangle count; clears any prior mesh when empty.
1031    pub fn upload_mesh(&mut self, positions: &[[f32; 3]], indices: &[u32]) -> u32 {
1032        self.upload_mesh_colored(positions, &[], indices)
1033    }
1034
1035    /// Upload a triangle mesh with per-vertex linear RGBA colours. When `colors` is empty the
1036    /// engine's neutral blue-grey material is used; any non-empty slice must match `positions`.
1037    pub fn upload_mesh_colored(
1038        &mut self,
1039        positions: &[[f32; 3]],
1040        colors: &[[f32; 4]],
1041        indices: &[u32],
1042    ) -> u32 {
1043        if positions.is_empty() || indices.len() < 3 {
1044            self.mesh = None;
1045            return 0;
1046        }
1047        if !colors.is_empty() && colors.len() != positions.len() {
1048            self.mesh = None;
1049            return 0;
1050        }
1051        let default_colors;
1052        let colors = if colors.is_empty() {
1053            default_colors = vec![[0.50, 0.60, 0.82, 1.0]; positions.len()];
1054            default_colors.as_slice()
1055        } else {
1056            colors
1057        };
1058        let vertex_buf = self
1059            .device
1060            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1061                label: Some("portal-mesh-verts"),
1062                contents: bytemuck::cast_slice(positions),
1063                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1064            });
1065        let color_buf = self
1066            .device
1067            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1068                label: Some("portal-mesh-colors"),
1069                contents: bytemuck::cast_slice(colors),
1070                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1071            });
1072        let index_buf = self
1073            .device
1074            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
1075                label: Some("portal-mesh-indices"),
1076                contents: bytemuck::cast_slice(indices),
1077                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1078            });
1079        let index_count = indices.len() as u32;
1080        self.mesh = Some(MeshGpu {
1081            vertex_buf,
1082            color_buf,
1083            index_buf,
1084            index_count,
1085        });
1086        self.mesh_base_aabb = Aabb::from_points(positions); // for Phase 2 admission
1087        self.last_admitted = Motor::identity();
1088        self.last_refused = false;
1089        index_count / 3
1090    }
1091
1092    /// Whether a mesh surface is resident.
1093    pub fn has_mesh(&self) -> bool {
1094        self.mesh.is_some()
1095    }
1096
1097    pub fn set_camera(&mut self, yaw: f32, pitch: f32, zoom: f32) {
1098        self.camera = CameraState { yaw, pitch, zoom }.clamped();
1099    }
1100
1101    pub fn set_standpoint(&mut self, observer: ObserverStandpoint) {
1102        self.observer = observer;
1103    }
1104
1105    pub fn observer_standpoint(&self) -> ObserverStandpoint {
1106        self.observer
1107    }
1108
1109    pub fn camera_state(&self) -> CameraState {
1110        self.camera
1111    }
1112
1113    /// Configured surface/depth size. The swapchain texture follows the canvas backing store,
1114    /// so callers compare this to `canvas.width()/height()` and `resize()` on divergence —
1115    /// otherwise color and depth attachments mismatch and the render pass fails validation.
1116    pub fn surface_size(&self) -> (u32, u32) {
1117        (self.width, self.height)
1118    }
1119
1120    pub fn resize(&mut self, width: u32, height: u32) {
1121        if width == 0 || height == 0 {
1122            return;
1123        }
1124        self.width = width;
1125        self.height = height;
1126        if let (Some(surface), Some(config)) = (self.surface.as_ref(), self.config.as_mut()) {
1127            config.width = width;
1128            config.height = height;
1129            surface.configure(&self.device, config);
1130        } else {
1131            self.offscreen_texture = Some(create_offscreen_texture(
1132                &self.device,
1133                self.color_format,
1134                width,
1135                height,
1136            ));
1137            self.readback_bytes_per_row = padded_bytes_per_row(width);
1138            self.readback_buf = Some(create_readback_buffer(
1139                &self.device,
1140                self.readback_bytes_per_row,
1141                height,
1142            ));
1143        }
1144        let (depth_texture, depth_view) = create_depth_texture(&self.device, width, height);
1145        let (picking_texture, picking_view) = create_picking_texture(&self.device, width, height);
1146        self.depth_texture = depth_texture;
1147        self.depth_view = depth_view;
1148        self.picking_texture = picking_texture;
1149        self.picking_view = picking_view;
1150        self.sync_bloom_targets();
1151    }
1152
1153    /// Reconcile HDR bloom textures with current `VramLedger` operational mode.
1154    pub fn sync_bloom_targets(&mut self) {
1155        if portal_bloom_enabled() && probe_hdr_format(&self.device) {
1156            let bloom =
1157                create_bloom_chain(&self.device, self.width, self.height, self.color_format);
1158            if let Some(ref chain) = bloom {
1159                let (hw, hh) = chain.hdr_extent();
1160                let (bw, bh) = chain.blur_extent();
1161                debug_assert_eq!(hw, self.width);
1162                debug_assert_eq!(hh, self.height);
1163                debug_assert_eq!(bw, (self.width / 2).max(1));
1164                debug_assert_eq!(bh, (self.height / 2).max(1));
1165                let _ = chain.texture_handles();
1166            }
1167            let bloom_bytes = bloom.as_ref().map(|b| b.vram_bytes).unwrap_or(0);
1168            let particle_bytes =
1169                (self.particle_count as usize * std::mem::size_of::<ParticleInstance>()) as u64;
1170            global_vram_ledger().record_render(particle_bytes + bloom_bytes);
1171            self.bloom = bloom;
1172        } else {
1173            let particle_bytes =
1174                (self.particle_count as usize * std::mem::size_of::<ParticleInstance>()) as u64;
1175            global_vram_ledger().record_render(particle_bytes);
1176            self.bloom = None;
1177        }
1178    }
1179
1180    fn write_camera_uniform(&self, time: f32) {
1181        let aspect = self.width as f32 / self.height.max(1) as f32;
1182        let mut uniform = self
1183            .camera
1184            .to_uniform(aspect, self.tensor_raw_buf.is_some());
1185        uniform._padding[0] = time;
1186        self.queue
1187            .write_buffer(&self.camera_buf, 0, bytemuck::bytes_of(&uniform));
1188    }
1189
1190    fn write_observer_uniform(&self) {
1191        self.queue
1192            .write_buffer(&self.observer_buf, 0, bytemuck::bytes_of(&self.observer));
1193    }
1194
1195    pub fn queue_pick(&mut self, x: f32, y: f32) {
1196        let px = x.round().max(0.0) as u32;
1197        let py = y.round().max(0.0) as u32;
1198        self.pending_pick = Some((
1199            px.min(self.width.saturating_sub(1)),
1200            py.min(self.height.saturating_sub(1)),
1201        ));
1202        self.pick_copy_submitted = false;
1203        self.pick_result = None;
1204    }
1205
1206    pub fn poll_pick_readback(&mut self) -> Option<u32> {
1207        if let Some(idx) = self.pick_result.take() {
1208            return Some(idx);
1209        }
1210        if !self.pick_copy_submitted {
1211            return None;
1212        }
1213        let slice = self.pick_staging_buf.slice(..);
1214        let (tx, rx) = std::sync::mpsc::channel();
1215        slice.map_async(wgpu::MapMode::Read, move |result| {
1216            let _ = tx.send(result);
1217        });
1218        let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
1219        if !matches!(rx.try_recv(), Ok(Ok(()))) {
1220            return None;
1221        }
1222        let mapped = slice
1223            .get_mapped_range()
1224            .expect("wgpu buffer map_range failed");
1225        let raw = if mapped.len() >= 4 {
1226            Some(u32::from_le_bytes(mapped[0..4].try_into().unwrap()))
1227        } else {
1228            None
1229        };
1230        drop(mapped);
1231        self.pick_staging_buf.unmap();
1232        self.pick_copy_submitted = false;
1233        raw.filter(|&id| id != PICK_SENTINEL)
1234    }
1235
1236    fn record_picking_pass(&self, encoder: &mut wgpu::CommandEncoder) {
1237        let Some(tensor_bind) = self.projector_tensor_bind.as_ref() else {
1238            return;
1239        };
1240        let count = self.tensor_node_count;
1241        if count == 0 {
1242            return;
1243        }
1244
1245        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1246            label: Some("portal-picking-pass"),
1247            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1248                view: &self.picking_view,
1249                depth_slice: None,
1250                resolve_target: None,
1251                ops: wgpu::Operations {
1252                    load: wgpu::LoadOp::Clear(wgpu::Color {
1253                        r: PICK_SENTINEL as f64,
1254                        g: 0.0,
1255                        b: 0.0,
1256                        a: 1.0,
1257                    }),
1258                    store: wgpu::StoreOp::Store,
1259                },
1260            })],
1261            depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1262                view: &self.depth_view,
1263                depth_ops: Some(wgpu::Operations {
1264                    load: wgpu::LoadOp::Clear(1.0),
1265                    store: wgpu::StoreOp::Store,
1266                }),
1267                stencil_ops: None,
1268            }),
1269            occlusion_query_set: None,
1270            multiview_mask: None,
1271            timestamp_writes: None,
1272        });
1273        pass.set_pipeline(&self.picking_pipeline);
1274        pass.set_bind_group(0, &self.projector_camera_bind, &[]);
1275        pass.set_bind_group(1, tensor_bind, &[]);
1276        pass.draw(0..6, 0..count);
1277    }
1278
1279    fn record_pick_copy(&mut self, encoder: &mut wgpu::CommandEncoder) {
1280        let Some((px, py)) = self.pending_pick.take() else {
1281            return;
1282        };
1283        let py = self.height.saturating_sub(1) - py;
1284        encoder.copy_texture_to_buffer(
1285            wgpu::TexelCopyTextureInfo {
1286                texture: &self.picking_texture,
1287                mip_level: 0,
1288                origin: wgpu::Origin3d { x: px, y: py, z: 0 },
1289                aspect: wgpu::TextureAspect::All,
1290            },
1291            wgpu::TexelCopyBufferInfo {
1292                buffer: &self.pick_staging_buf,
1293                layout: wgpu::TexelCopyBufferLayout {
1294                    offset: 0,
1295                    bytes_per_row: Some(4),
1296                    rows_per_image: Some(1),
1297                },
1298            },
1299            wgpu::Extent3d {
1300                width: 1,
1301                height: 1,
1302                depth_or_array_layers: 1,
1303            },
1304        );
1305        self.pick_copy_submitted = true;
1306    }
1307
1308    pub fn render(&mut self, time: f32, telemetry: &SystemTelemetry) -> Result<(), String> {
1309        let uniforms = AmbientUniforms {
1310            time,
1311            view_width: self.width as f32,
1312            view_height: self.height as f32,
1313            _padding: 0.0,
1314        };
1315        self.queue
1316            .write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&uniforms));
1317        self.queue
1318            .write_buffer(&self.telemetry_buf, 0, bytemuck::bytes_of(telemetry));
1319        self.write_camera_uniform(time);
1320        self.write_observer_uniform();
1321        self.update_model(time);
1322
1323        // A browser target acquires a swapchain frame; a native/headless target keeps a reusable
1324        // COPY_SRC texture. The draw graph below is identical for both.
1325        let surface_frame = if let Some(surface) = self.surface.as_ref() {
1326            match surface.get_current_texture() {
1327                wgpu::CurrentSurfaceTexture::Success(t)
1328                | wgpu::CurrentSurfaceTexture::Suboptimal(t) => Some(t),
1329                other => return Err(format!("surface frame unavailable: {other:?}")),
1330            }
1331        } else {
1332            None
1333        };
1334
1335        // On the web backend the swapchain texture tracks the canvas backing store, which can
1336        // diverge from the size our depth/picking/bloom targets were built at (device-pixel ratio,
1337        // layout settle, a ResizeObserver resizing in CSS pixels without a matching `resize()`).
1338        // A depth attachment whose dimensions don't match the colour attachment fails render-pass
1339        // validation, the whole frame is dropped, and the viewport stays black. Reconcile every
1340        // attachment to the *actual* acquired texture before recording any pass.
1341        if let Some(frame) = surface_frame.as_ref() {
1342            let fw = frame.texture.width();
1343            let fh = frame.texture.height();
1344            if fw > 0 && fh > 0 && (fw, fh) != (self.width, self.height) {
1345                self.width = fw;
1346                self.height = fh;
1347                if let Some(config) = self.config.as_mut() {
1348                    config.width = fw;
1349                    config.height = fh;
1350                }
1351                let (depth_texture, depth_view) = create_depth_texture(&self.device, fw, fh);
1352                let (picking_texture, picking_view) = create_picking_texture(&self.device, fw, fh);
1353                self.depth_texture = depth_texture;
1354                self.depth_view = depth_view;
1355                self.picking_texture = picking_texture;
1356                self.picking_view = picking_view;
1357                self.sync_bloom_targets();
1358                self.write_camera_uniform(time);
1359            }
1360        }
1361
1362        let view = if let Some(frame) = surface_frame.as_ref() {
1363            frame
1364                .texture
1365                .create_view(&wgpu::TextureViewDescriptor::default())
1366        } else {
1367            self.offscreen_texture
1368                .as_ref()
1369                .ok_or_else(|| "renderer has no output target".to_string())?
1370                .create_view(&wgpu::TextureViewDescriptor::default())
1371        };
1372
1373        let mut encoder = self
1374            .device
1375            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1376                label: Some("portal-viewport-encoder"),
1377            });
1378
1379        self.record_picking_pass(&mut encoder);
1380
1381        let use_bloom = self.bloom.is_some()
1382            && self.ambient_pipeline_hdr.is_some()
1383            && self.projector_pipeline_hdr.is_some()
1384            && portal_bloom_enabled();
1385
1386        if use_bloom {
1387            let bloom = self.bloom.as_ref().expect("bloom chain");
1388            let ambient_hdr = self.ambient_pipeline_hdr.as_ref().expect("ambient hdr");
1389            let projector_hdr = self.projector_pipeline_hdr.as_ref().expect("projector hdr");
1390            let mesh_hdr = self.mesh_pipeline_hdr.as_ref();
1391
1392            {
1393                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1394                    label: Some("portal-hdr-scene"),
1395                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1396                        view: &bloom.hdr_view,
1397                        depth_slice: None,
1398                        resolve_target: None,
1399                        ops: wgpu::Operations {
1400                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1401                            store: wgpu::StoreOp::Store,
1402                        },
1403                    })],
1404                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1405                        view: &self.depth_view,
1406                        depth_ops: Some(wgpu::Operations {
1407                            load: wgpu::LoadOp::Clear(1.0),
1408                            store: wgpu::StoreOp::Store,
1409                        }),
1410                        stencil_ops: None,
1411                    }),
1412                    occlusion_query_set: None,
1413                    multiview_mask: None,
1414                    timestamp_writes: None,
1415                });
1416
1417                if let (Some(mesh), Some(mesh_pipe)) = (self.mesh.as_ref(), mesh_hdr) {
1418                    pass.set_pipeline(mesh_pipe);
1419                    pass.set_bind_group(0, &self.projector_camera_bind, &[]);
1420                    pass.set_bind_group(1, &self.mesh_model_bind, &[]);
1421                    pass.set_vertex_buffer(0, mesh.vertex_buf.slice(..));
1422                    pass.set_vertex_buffer(1, mesh.color_buf.slice(..));
1423                    pass.set_index_buffer(mesh.index_buf.slice(..), wgpu::IndexFormat::Uint32);
1424                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1425                }
1426
1427                if let (Some(tensor_bind), count) =
1428                    (self.projector_tensor_bind.as_ref(), self.tensor_node_count)
1429                {
1430                    if count > 0 {
1431                        pass.set_pipeline(projector_hdr);
1432                        pass.set_bind_group(0, &self.projector_camera_bind, &[]);
1433                        pass.set_bind_group(1, tensor_bind, &[]);
1434                        pass.draw(0..6, 0..count);
1435                    }
1436                }
1437
1438                pass.set_pipeline(ambient_hdr);
1439                pass.set_bind_group(0, &self.ambient_bind_group, &[]);
1440                let ambient_draw = ambient_draw_instances(self.particle_count);
1441                if self.ambient_enabled && ambient_draw > 0 {
1442                    pass.draw(0..6, 0..ambient_draw);
1443                }
1444            }
1445
1446            run_bloom_passes(&mut encoder, bloom, &self.queue, &self.device, &view);
1447        } else {
1448            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1449                label: Some("portal-phenomenal-pass"),
1450                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1451                    view: &view,
1452                    depth_slice: None,
1453                    resolve_target: None,
1454                    ops: wgpu::Operations {
1455                        load: wgpu::LoadOp::Clear(wgpu::Color {
1456                            r: 0.03,
1457                            g: 0.05,
1458                            b: 0.08,
1459                            a: 1.0,
1460                        }),
1461                        store: wgpu::StoreOp::Store,
1462                    },
1463                })],
1464                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1465                    view: &self.depth_view,
1466                    depth_ops: Some(wgpu::Operations {
1467                        load: wgpu::LoadOp::Clear(1.0),
1468                        store: wgpu::StoreOp::Store,
1469                    }),
1470                    stencil_ops: None,
1471                }),
1472                occlusion_query_set: None,
1473                multiview_mask: None,
1474                timestamp_writes: None,
1475            });
1476
1477            if let Some(mesh) = self.mesh.as_ref() {
1478                pass.set_pipeline(&self.mesh_pipeline);
1479                pass.set_bind_group(0, &self.projector_camera_bind, &[]);
1480                pass.set_bind_group(1, &self.mesh_model_bind, &[]);
1481                pass.set_vertex_buffer(0, mesh.vertex_buf.slice(..));
1482                pass.set_vertex_buffer(1, mesh.color_buf.slice(..));
1483                pass.set_index_buffer(mesh.index_buf.slice(..), wgpu::IndexFormat::Uint32);
1484                pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1485            }
1486
1487            if let (Some(tensor_bind), count) =
1488                (self.projector_tensor_bind.as_ref(), self.tensor_node_count)
1489            {
1490                if count > 0 {
1491                    pass.set_pipeline(&self.projector_pipeline);
1492                    pass.set_bind_group(0, &self.projector_camera_bind, &[]);
1493                    pass.set_bind_group(1, tensor_bind, &[]);
1494                    pass.draw(0..6, 0..count);
1495                }
1496            }
1497
1498            pass.set_pipeline(&self.ambient_pipeline);
1499            pass.set_bind_group(0, &self.ambient_bind_group, &[]);
1500            let ambient_draw = ambient_draw_instances(self.particle_count);
1501            if self.ambient_enabled && ambient_draw > 0 {
1502                pass.draw(0..6, 0..ambient_draw);
1503            }
1504        }
1505
1506        self.record_pick_copy(&mut encoder);
1507        self.queue.submit(std::iter::once(encoder.finish()));
1508        if let Some(frame) = surface_frame {
1509            // wgpu 30: SurfaceTexture::present() removed → Queue::present(frame).
1510            self.queue.present(frame);
1511        }
1512        Ok(())
1513    }
1514
1515    /// Number of bytes required by [`Self::read_rgba8_into`].
1516    pub fn required_rgba8_bytes(&self) -> usize {
1517        self.width as usize * self.height as usize * 4
1518    }
1519
1520    /// Read the most recently rendered native offscreen frame into tightly packed RGBA8 bytes.
1521    ///
1522    /// This is deliberately caller-buffered: no `Vec` is created in the renderer. Browser surface
1523    /// instances return an error because their swapchain images are presented, not retained.
1524    #[cfg(not(target_arch = "wasm32"))]
1525    pub fn read_rgba8_into(&self, out: &mut [u8]) -> Result<usize, String> {
1526        let need = self.required_rgba8_bytes();
1527        if out.len() < need {
1528            return Err(format!(
1529                "RGBA8 output buffer too small: need {need}, got {}",
1530                out.len()
1531            ));
1532        }
1533        let texture = self
1534            .offscreen_texture
1535            .as_ref()
1536            .ok_or_else(|| "RGBA8 readback requires an offscreen renderer".to_string())?;
1537        let staging = self
1538            .readback_buf
1539            .as_ref()
1540            .ok_or_else(|| "offscreen readback buffer is unavailable".to_string())?;
1541
1542        let mut encoder = self
1543            .device
1544            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1545                label: Some("qualia-render-readback-encoder"),
1546            });
1547        encoder.copy_texture_to_buffer(
1548            wgpu::TexelCopyTextureInfo {
1549                texture,
1550                mip_level: 0,
1551                origin: wgpu::Origin3d::ZERO,
1552                aspect: wgpu::TextureAspect::All,
1553            },
1554            wgpu::TexelCopyBufferInfo {
1555                buffer: staging,
1556                layout: wgpu::TexelCopyBufferLayout {
1557                    offset: 0,
1558                    bytes_per_row: Some(self.readback_bytes_per_row),
1559                    rows_per_image: Some(self.height),
1560                },
1561            },
1562            wgpu::Extent3d {
1563                width: self.width,
1564                height: self.height,
1565                depth_or_array_layers: 1,
1566            },
1567        );
1568        self.queue.submit(std::iter::once(encoder.finish()));
1569
1570        let slice = staging.slice(..);
1571        let (tx, rx) = std::sync::mpsc::channel();
1572        slice.map_async(wgpu::MapMode::Read, move |result| {
1573            let _ = tx.send(result);
1574        });
1575        let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
1576        rx.recv()
1577            .map_err(|e| format!("RGBA8 readback callback failed: {e}"))?
1578            .map_err(|e| format!("RGBA8 buffer map failed: {e}"))?;
1579
1580        let mapped = slice
1581            .get_mapped_range()
1582            .expect("wgpu buffer map_range failed");
1583        let tight_row = self.width as usize * 4;
1584        let padded_row = self.readback_bytes_per_row as usize;
1585        for row in 0..self.height as usize {
1586            let src = &mapped[row * padded_row..row * padded_row + tight_row];
1587            let dst = &mut out[row * tight_row..(row + 1) * tight_row];
1588            dst.copy_from_slice(src);
1589        }
1590        drop(mapped);
1591        staging.unmap();
1592        Ok(need)
1593    }
1594
1595    pub fn particle_count(&self) -> u32 {
1596        self.particle_count
1597    }
1598}
1599
1600// Phase 0.2a: render/gpu submodules (bloom post-pass, resource builders, particle field).
1601mod bloom;
1602mod particles;
1603mod resources;
1604use bloom::*;
1605pub use particles::particle_cap_for_mode;
1606use particles::*;
1607use resources::*;
1608
1609#[cfg(all(test, not(target_arch = "wasm32")))]
1610mod tests {
1611    use super::*;
1612    use crate::tensor::buffer_export::{write_tensor_buffer, TensorBufferHeader};
1613    use crate::tensor::Tensor10D;
1614
1615    #[test]
1616    fn offscreen_size_contract_is_caller_buffered() {
1617        assert_eq!(padded_bytes_per_row(1), wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
1618        assert_eq!(padded_bytes_per_row(64), 256);
1619        assert_eq!(padded_bytes_per_row(65), 512);
1620    }
1621
1622    #[test]
1623    #[serial_test::serial(gpu)]
1624    fn native_offscreen_renders_tensor_and_mesh_on_shared_gpu() {
1625        if !crate::wgsl_forge::test_gpu_available() {
1626            return;
1627        }
1628        let mut renderer =
1629            PortalGpu::new_offscreen(96, 96, 256).expect("native offscreen renderer");
1630
1631        let tensors = [
1632            Tensor10D::ground_truth(0.0, 0.0, -0.35, 0.0, 0.0, 0.0, 1.0, 0.0, 0.2),
1633            Tensor10D::ground_truth(0.0, 0.0, 0.35, 0.0, 0.1, 0.0, 1.0, 0.0, 0.8),
1634        ];
1635        let mut tensor_bytes = vec![0u8; TensorBufferHeader::total_bytes(tensors.len())];
1636        write_tensor_buffer(&tensors, &mut tensor_bytes).expect("tensor export");
1637        assert_eq!(
1638            renderer
1639                .upload_tensor_buffer(&tensor_bytes)
1640                .expect("tensor upload"),
1641            2
1642        );
1643        assert_eq!(
1644            renderer.upload_mesh(
1645                &[[-0.6, -0.5, 0.2], [0.6, -0.5, 0.2], [0.0, 0.6, 0.2]],
1646                &[0, 1, 2],
1647            ),
1648            1
1649        );
1650
1651        renderer
1652            .render(0.25, &SystemTelemetry::default())
1653            .expect("offscreen draw");
1654        let mut rgba = vec![0u8; renderer.required_rgba8_bytes()];
1655        assert_eq!(
1656            renderer
1657                .read_rgba8_into(&mut rgba)
1658                .expect("offscreen readback"),
1659            rgba.len()
1660        );
1661        assert!(
1662            rgba.chunks_exact(4)
1663                .any(|px| px != [8, 13, 20, 255] && px[3] != 0),
1664            "expected projected tensor, mesh, or ambient pixels over the clear colour"
1665        );
1666    }
1667}