1use 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
25const 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
59struct 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 pub(super) fn hdr_extent(&self) -> (u32, u32) {
82 (self.hdr_texture.width(), self.hdr_texture.height())
83 }
84
85 pub(super) fn blur_extent(&self) -> (u32, u32) {
87 (self.half_width, self.half_height)
88 }
89
90 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
96struct 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 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 ambient_enabled: bool,
164 width: u32,
165 height: u32,
166}
167
168impl PortalGpu {
169 #[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 #[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 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 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 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 #[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()
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 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 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 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 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 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 #[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 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 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 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 pub fn set_artefact_joint(&mut self, joint: Option<Joint>) {
971 self.artefact_joint = joint;
972 self.artefact_t0 = None; self.last_admitted = Motor::identity();
974 self.last_refused = false;
975 }
976
977 pub fn set_artefact_world(&mut self, world: Option<Aabb>) {
980 self.artefact_world = world;
981 }
982
983 pub fn artefact_refused(&self) -> bool {
985 self.last_refused
986 }
987
988 fn update_model(&mut self, time: f32) {
991 let proposed = match self.artefact_joint {
992 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 }
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 pub fn upload_mesh(&mut self, positions: &[[f32; 3]], indices: &[u32]) -> u32 {
1032 self.upload_mesh_colored(positions, &[], indices)
1033 }
1034
1035 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); self.last_admitted = Motor::identity();
1088 self.last_refused = false;
1089 index_count / 3
1090 }
1091
1092 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 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 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 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 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 self.queue.present(frame);
1511 }
1512 Ok(())
1513 }
1514
1515 pub fn required_rgba8_bytes(&self) -> usize {
1517 self.width as usize * self.height as usize * 4
1518 }
1519
1520 #[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
1600mod 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}