diff --git a/docs/superpowers/plans/2026-03-26-scene-viewport.md b/docs/superpowers/plans/2026-03-26-scene-viewport.md new file mode 100644 index 0000000..517e7e0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-scene-viewport.md @@ -0,0 +1,773 @@ +# Scene Viewport Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Embed a 3D scene viewport inside the editor's docking panel with orbit camera controls and Blinn-Phong rendering. + +**Architecture:** Render 3D scene to an offscreen texture (Rgba8Unorm + Depth32Float), then blit that texture to the surface as a textured quad within the viewport panel's rect. OrbitCamera provides view/projection. Reuse existing mesh_shader.wgsl pipeline for 3D rendering. + +**Tech Stack:** Rust, wgpu 28.0, voltex_math (Vec3/Mat4), voltex_renderer (Mesh/MeshVertex/CameraUniform/LightUniform/pipeline), voltex_editor (docking/IMGUI) + +**Spec:** `docs/superpowers/specs/2026-03-26-scene-viewport-design.md` + +--- + +### Task 1: OrbitCamera (pure math, no GPU) + +**Files:** +- Create: `crates/voltex_editor/src/orbit_camera.rs` +- Modify: `crates/voltex_editor/src/lib.rs` +- Modify: `crates/voltex_editor/Cargo.toml` + +- [ ] **Step 1: Add voltex_math dependency** + +In `crates/voltex_editor/Cargo.toml`, add under `[dependencies]`: +```toml +voltex_math.workspace = true +``` + +- [ ] **Step 2: Write failing tests** + +Create `crates/voltex_editor/src/orbit_camera.rs` with tests: + +```rust +use voltex_math::{Vec3, Mat4}; + +use std::f32::consts::PI; + +const PITCH_LIMIT: f32 = PI / 2.0 - 0.01; +const MIN_DISTANCE: f32 = 0.5; +const MAX_DISTANCE: f32 = 50.0; +const ORBIT_SENSITIVITY: f32 = 0.005; +const ZOOM_FACTOR: f32 = 0.1; +const PAN_SENSITIVITY: f32 = 0.01; + +pub struct OrbitCamera { + pub target: Vec3, + pub distance: f32, + pub yaw: f32, + pub pitch: f32, + pub fov_y: f32, + pub near: f32, + pub far: f32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_position() { + let cam = OrbitCamera::new(); + let pos = cam.position(); + // Default: yaw=0, pitch=0.3, distance=5 + // pos = target + (d*cos(p)*sin(y), d*sin(p), d*cos(p)*cos(y)) + // yaw=0 → sin=0, cos=1 → x=0, z=d*cos(p) + assert!((pos.x).abs() < 1e-3); + assert!(pos.y > 0.0); // pitch > 0 → y > 0 + assert!(pos.z > 0.0); // cos(0)*cos(p) > 0 + } + + #[test] + fn test_orbit_changes_yaw_pitch() { + let mut cam = OrbitCamera::new(); + let old_yaw = cam.yaw; + let old_pitch = cam.pitch; + cam.orbit(100.0, 50.0); + assert!((cam.yaw - old_yaw - 100.0 * ORBIT_SENSITIVITY).abs() < 1e-6); + assert!((cam.pitch - old_pitch - 50.0 * ORBIT_SENSITIVITY).abs() < 1e-6); + } + + #[test] + fn test_pitch_clamped() { + let mut cam = OrbitCamera::new(); + cam.orbit(0.0, 100000.0); // huge pitch + assert!(cam.pitch <= PITCH_LIMIT); + cam.orbit(0.0, -200000.0); // huge negative + assert!(cam.pitch >= -PITCH_LIMIT); + } + + #[test] + fn test_zoom_changes_distance() { + let mut cam = OrbitCamera::new(); + let d0 = cam.distance; + cam.zoom(1.0); // scroll up → zoom in + assert!(cam.distance < d0); + } + + #[test] + fn test_zoom_clamped() { + let mut cam = OrbitCamera::new(); + cam.zoom(1000.0); // massive zoom in + assert!(cam.distance >= MIN_DISTANCE); + cam.zoom(-10000.0); // massive zoom out + assert!(cam.distance <= MAX_DISTANCE); + } + + #[test] + fn test_pan_moves_target() { + let mut cam = OrbitCamera::new(); + let t0 = cam.target; + cam.pan(10.0, 0.0); // pan right + assert!((cam.target.x - t0.x).abs() > 1e-4 || (cam.target.z - t0.z).abs() > 1e-4); + } + + #[test] + fn test_view_matrix_not_zero() { + let cam = OrbitCamera::new(); + let v = cam.view_matrix(); + // At least some elements should be non-zero + let sum: f32 = v.cols.iter().flat_map(|c| c.iter()).map(|x| x.abs()).sum(); + assert!(sum > 1.0); + } + + #[test] + fn test_projection_matrix() { + let cam = OrbitCamera::new(); + let p = cam.projection_matrix(16.0 / 9.0); + // Element [0][0] should be related to fov and aspect + assert!(p.cols[0][0] > 0.0); + } + + #[test] + fn test_view_projection() { + let cam = OrbitCamera::new(); + let vp = cam.view_projection(1.0); + let v = cam.view_matrix(); + let p = cam.projection_matrix(1.0); + let expected = p.mul_mat4(&v); + for i in 0..4 { + for j in 0..4 { + assert!((vp.cols[i][j] - expected.cols[i][j]).abs() < 1e-4, + "mismatch at [{i}][{j}]: {} vs {}", vp.cols[i][j], expected.cols[i][j]); + } + } + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cargo test -p voltex_editor --lib orbit_camera` +Expected: FAIL (methods don't exist) + +- [ ] **Step 4: Implement OrbitCamera** + +```rust +impl OrbitCamera { + pub fn new() -> Self { + OrbitCamera { + target: Vec3::ZERO, + distance: 5.0, + yaw: 0.0, + pitch: 0.3, + fov_y: PI / 4.0, + near: 0.1, + far: 100.0, + } + } + + pub fn position(&self) -> Vec3 { + let cp = self.pitch.cos(); + let sp = self.pitch.sin(); + let cy = self.yaw.cos(); + let sy = self.yaw.sin(); + Vec3::new( + self.target.x + self.distance * cp * sy, + self.target.y + self.distance * sp, + self.target.z + self.distance * cp * cy, + ) + } + + pub fn orbit(&mut self, dx: f32, dy: f32) { + self.yaw += dx * ORBIT_SENSITIVITY; + self.pitch += dy * ORBIT_SENSITIVITY; + self.pitch = self.pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT); + } + + pub fn zoom(&mut self, delta: f32) { + self.distance *= 1.0 - delta * ZOOM_FACTOR; + self.distance = self.distance.clamp(MIN_DISTANCE, MAX_DISTANCE); + } + + pub fn pan(&mut self, dx: f32, dy: f32) { + let forward = (self.target - self.position()).normalize(); + let right = forward.cross(Vec3::Y); + let right = if right.length() < 1e-4 { Vec3::X } else { right.normalize() }; + let up = right.cross(forward).normalize(); + let offset_x = right * (-dx * PAN_SENSITIVITY * self.distance); + let offset_y = up * (dy * PAN_SENSITIVITY * self.distance); + self.target = self.target + offset_x + offset_y; + } + + pub fn view_matrix(&self) -> Mat4 { + Mat4::look_at(self.position(), self.target, Vec3::Y) + } + + pub fn projection_matrix(&self, aspect: f32) -> Mat4 { + Mat4::perspective(self.fov_y, aspect, self.near, self.far) + } + + pub fn view_projection(&self, aspect: f32) -> Mat4 { + self.projection_matrix(aspect).mul_mat4(&self.view_matrix()) + } +} +``` + +- [ ] **Step 5: Add module to lib.rs** + +```rust +pub mod orbit_camera; +pub use orbit_camera::OrbitCamera; +``` + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p voltex_editor --lib orbit_camera -- --nocapture` +Expected: all 9 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add crates/voltex_editor/Cargo.toml crates/voltex_editor/src/orbit_camera.rs crates/voltex_editor/src/lib.rs +git commit -m "feat(editor): add OrbitCamera with orbit, zoom, pan controls" +``` + +--- + +### Task 2: ViewportTexture (offscreen render target) + +**Files:** +- Create: `crates/voltex_editor/src/viewport_texture.rs` +- Modify: `crates/voltex_editor/src/lib.rs` + +- [ ] **Step 1: Implement ViewportTexture** + +Create `crates/voltex_editor/src/viewport_texture.rs`: + +```rust +pub const VIEWPORT_COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +pub const VIEWPORT_DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; + +pub struct ViewportTexture { + pub color_texture: wgpu::Texture, + pub color_view: wgpu::TextureView, + pub depth_texture: wgpu::Texture, + pub depth_view: wgpu::TextureView, + pub width: u32, + pub height: u32, +} + +impl ViewportTexture { + pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { + let w = width.max(1); + let h = height.max(1); + + let color_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Viewport Color"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: VIEWPORT_COLOR_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let depth_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Viewport Depth"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: VIEWPORT_DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let depth_view = depth_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + ViewportTexture { color_texture, color_view, depth_texture, depth_view, width: w, height: h } + } + + /// Recreate textures if size changed. Returns true if recreated. + pub fn ensure_size(&mut self, device: &wgpu::Device, width: u32, height: u32) -> bool { + let w = width.max(1); + let h = height.max(1); + if w == self.width && h == self.height { + return false; + } + *self = Self::new(device, w, h); + true + } +} +``` + +- [ ] **Step 2: Add module to lib.rs** + +```rust +pub mod viewport_texture; +pub use viewport_texture::ViewportTexture; +``` + +- [ ] **Step 3: Build and verify** + +Run: `cargo build -p voltex_editor` +Expected: compiles (no GPU tests possible, but struct/logic is trivial) + +- [ ] **Step 4: Commit** + +```bash +git add crates/voltex_editor/src/viewport_texture.rs crates/voltex_editor/src/lib.rs +git commit -m "feat(editor): add ViewportTexture offscreen render target" +``` + +--- + +### Task 3: ViewportRenderer (blit shader + pipeline) + +**Files:** +- Create: `crates/voltex_editor/src/viewport_renderer.rs` +- Create: `crates/voltex_editor/src/viewport_shader.wgsl` +- Modify: `crates/voltex_editor/src/lib.rs` + +- [ ] **Step 1: Write the WGSL shader** + +Create `crates/voltex_editor/src/viewport_shader.wgsl`: + +```wgsl +struct RectUniform { + rect: vec4, // x, y, w, h in pixels + screen: vec2, // screen_w, screen_h + _pad: vec2, +}; + +@group(0) @binding(0) var u: RectUniform; +@group(0) @binding(1) var t_viewport: texture_2d; +@group(0) @binding(2) var s_viewport: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput { + // 6 vertices for 2 triangles (fullscreen quad within rect) + var positions = array, 6>( + vec2(0.0, 0.0), // top-left + vec2(1.0, 0.0), // top-right + vec2(1.0, 1.0), // bottom-right + vec2(0.0, 0.0), // top-left + vec2(1.0, 1.0), // bottom-right + vec2(0.0, 1.0), // bottom-left + ); + + let p = positions[idx]; + // Convert pixel rect to NDC + let px = u.rect.x + p.x * u.rect.z; + let py = u.rect.y + p.y * u.rect.w; + let ndc_x = (px / u.screen.x) * 2.0 - 1.0; + let ndc_y = 1.0 - (py / u.screen.y) * 2.0; // Y flipped + + var out: VertexOutput; + out.position = vec4(ndc_x, ndc_y, 0.0, 1.0); + out.uv = p; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + return textureSample(t_viewport, s_viewport, in.uv); +} +``` + +- [ ] **Step 2: Implement ViewportRenderer** + +Create `crates/voltex_editor/src/viewport_renderer.rs`: + +```rust +use bytemuck::{Pod, Zeroable}; + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +struct RectUniform { + rect: [f32; 4], // x, y, w, h + screen: [f32; 2], // screen_w, screen_h + _pad: [f32; 2], +} + +pub struct ViewportRenderer { + pipeline: wgpu::RenderPipeline, + bind_group_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + uniform_buffer: wgpu::Buffer, +} + +impl ViewportRenderer { + pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Viewport Blit Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("viewport_shader.wgsl").into()), + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Viewport Blit BGL"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Viewport Blit PL"), + bind_group_layouts: &[&bind_group_layout], + immediate_size: 0, + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Viewport Blit Pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], // no vertex buffer + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Viewport Sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Viewport Rect Uniform"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + ViewportRenderer { pipeline, bind_group_layout, sampler, uniform_buffer } + } + + pub fn render( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + target_view: &wgpu::TextureView, + viewport_color_view: &wgpu::TextureView, + screen_w: f32, + screen_h: f32, + rect_x: f32, + rect_y: f32, + rect_w: f32, + rect_h: f32, + ) { + let uniform = RectUniform { + rect: [rect_x, rect_y, rect_w, rect_h], + screen: [screen_w, screen_h], + _pad: [0.0; 2], + }; + queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniform])); + + // Create bind group per frame (texture may change on resize) + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Viewport Blit BG"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: self.uniform_buffer.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(viewport_color_view) }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + ], + }); + + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Viewport Blit Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); + + // Scissor to panel area + rpass.set_scissor_rect(rect_x as u32, rect_y as u32, rect_w.ceil() as u32, rect_h.ceil() as u32); + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, &bind_group, &[]); + rpass.draw(0..6, 0..1); + } +} +``` + +- [ ] **Step 3: Add module to lib.rs** + +```rust +pub mod viewport_renderer; +pub use viewport_renderer::ViewportRenderer; +``` + +- [ ] **Step 4: Build** + +Run: `cargo build -p voltex_editor` +Expected: compiles + +- [ ] **Step 5: Commit** + +```bash +git add crates/voltex_editor/src/viewport_shader.wgsl crates/voltex_editor/src/viewport_renderer.rs crates/voltex_editor/src/lib.rs +git commit -m "feat(editor): add ViewportRenderer blit pipeline and shader" +``` + +--- + +### Task 4: Integrate into editor_demo + +**Files:** +- Modify: `crates/voltex_editor/Cargo.toml` +- Modify: `examples/editor_demo/src/main.rs` + +This is the largest task. The editor_demo needs: +- voltex_renderer dependency in voltex_editor for Mesh/MeshVertex/CameraUniform/LightUniform/pipeline +- A simple 3D scene (cubes + ground + light) +- Render scene to ViewportTexture, blit to surface, orbit camera input + +- [ ] **Step 1: Add voltex_renderer dependency** + +In `crates/voltex_editor/Cargo.toml`: +```toml +voltex_renderer.workspace = true +``` + +- [ ] **Step 2: Update editor_demo imports and state** + +Read `examples/editor_demo/src/main.rs` first. Then add: + +Imports: +```rust +use voltex_editor::{ + UiContext, UiRenderer, DockTree, DockNode, Axis, Rect, LayoutState, + OrbitCamera, ViewportTexture, ViewportRenderer, +}; +use voltex_renderer::{Mesh, MeshVertex, CameraUniform, LightUniform, GpuTexture}; +``` + +Add to `AppState`: +```rust +// Viewport state +orbit_cam: OrbitCamera, +viewport_tex: ViewportTexture, +viewport_renderer: ViewportRenderer, +// 3D scene resources +scene_pipeline: wgpu::RenderPipeline, +camera_buffer: wgpu::Buffer, +light_buffer: wgpu::Buffer, +camera_light_bg: wgpu::BindGroup, +scene_meshes: Vec<(Mesh, [[f32; 4]; 4])>, // mesh + model matrix +dummy_texture: GpuTexture, +// Mouse tracking for orbit +prev_mouse: (f32, f32), +left_dragging: bool, +middle_dragging: bool, +``` + +- [ ] **Step 3: Initialize 3D scene in `resumed`** + +After UI initialization, create: +1. OrbitCamera +2. ViewportTexture (initial size 640x480) +3. ViewportRenderer +4. Camera/Light bind group layout, buffers, bind group +5. Texture bind group layout + white 1x1 dummy texture +6. Mesh pipeline with Rgba8Unorm target format +7. Scene meshes: ground plane + 3 cubes (using MeshVertex) + +The scene pipeline must use `VIEWPORT_COLOR_FORMAT` (Rgba8Unorm) not `surface_format`. + +Helper to generate a cube mesh: +```rust +fn make_cube(device: &wgpu::Device) -> Mesh { + // 24 vertices (4 per face, 6 faces), 36 indices + // Each face: position, normal, uv=(0,0), tangent=(1,0,0,1) + // ... standard unit cube centered at origin +} + +fn make_ground(device: &wgpu::Device) -> Mesh { + // 4 vertices forming a 10x10 quad at y=0 + // normal = (0,1,0) +} +``` + +- [ ] **Step 4: Handle mouse input for orbit camera** + +In `window_event`: +- Track `prev_mouse` position for dx/dy calculation +- Track `left_dragging` and `middle_dragging` state from mouse button events +- Feed scroll delta for zoom + +In the render loop, after getting viewport rect: +```rust +if rect.contains(mx, my) { + let dx = mx - prev_mouse.0; + let dy = my - prev_mouse.1; + if left_dragging { orbit_cam.orbit(dx, dy); } + if middle_dragging { orbit_cam.pan(dx, dy); } + if scroll != 0.0 { orbit_cam.zoom(scroll); } +} +``` + +- [ ] **Step 5: Render 3D scene to viewport texture** + +In the render loop, for viewport panel: +```rust +1 => { + // Ensure viewport texture matches panel size + viewport_tex.ensure_size(&gpu.device, rect.w as u32, rect.h as u32); + + // Update camera uniform + let aspect = rect.w / rect.h; + let vp = orbit_cam.view_projection(aspect); + let cam_pos = orbit_cam.position(); + + // Render each mesh to viewport texture + // For each mesh: update model matrix in camera_uniform, write_buffer, draw + { + let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &viewport_tex.color_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.15, g: 0.15, b: 0.2, a: 1.0 }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &viewport_tex.depth_view, + depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store }), + stencil_ops: None, + }), + ..Default::default() + }); + + rpass.set_pipeline(&scene_pipeline); + rpass.set_bind_group(0, &camera_light_bg, &[]); + rpass.set_bind_group(1, &dummy_texture.bind_group, &[]); + + for (mesh, _model) in &scene_meshes { + rpass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + rpass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + rpass.draw_indexed(0..mesh.num_indices, 0, 0..1); + } + } + + // Blit viewport texture to surface + viewport_renderer.render( + &gpu.device, &gpu.queue, &mut encoder, &surface_view, + &viewport_tex.color_view, + screen_w, screen_h, + rect.x, rect.y, rect.w, rect.h, + ); +} +``` + +Note: For multiple meshes with different model matrices, we need to update the camera_buffer between draws. Since we can't write_buffer inside a render pass, we have two options: +- (A) Use a single model matrix (identity) and position cubes via vertex data +- (B) Use dynamic uniform buffer offsets + +For simplicity, option (A): pre-transform cube vertices at different positions. The model matrix in CameraUniform stays identity. + +- [ ] **Step 6: Build and test** + +Run: `cargo build -p editor_demo` +Expected: compiles + +Run: `cargo test -p voltex_editor -- --nocapture` +Expected: all tests pass (orbit_camera + dock tests) + +- [ ] **Step 7: Commit** + +```bash +git add crates/voltex_editor/Cargo.toml examples/editor_demo/src/main.rs +git commit -m "feat(editor): integrate 3D viewport into editor_demo" +``` + +--- + +### Task 5: Update docs + +**Files:** +- Modify: `docs/STATUS.md` +- Modify: `docs/DEFERRED.md` + +- [ ] **Step 1: Update STATUS.md** + +Add to Phase 8-4: +``` +- voltex_editor: ViewportTexture, ViewportRenderer, OrbitCamera (offscreen 3D viewport) +``` + +Update test count. + +- [ ] **Step 2: Update DEFERRED.md** + +``` +- ~~**씬 뷰포트**~~ ✅ 오프스크린 렌더링 + 오빗 카메라 완료. Blinn-Phong forward만 (디퍼드/PBR 미지원). +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/STATUS.md docs/DEFERRED.md +git commit -m "docs: update STATUS.md and DEFERRED.md with scene viewport" +```