diff --git a/bin/rts.exe b/bin/rts.exe new file mode 100644 index 00000000..7a5e44a7 Binary files /dev/null and b/bin/rts.exe differ diff --git a/crates/rts-codegen-new/src/front/run/funcval/mod.rs b/crates/rts-codegen-new/src/front/run/funcval/mod.rs index 8c4d4792..e88b5754 100644 --- a/crates/rts-codegen-new/src/front/run/funcval/mod.rs +++ b/crates/rts-codegen-new/src/front/run/funcval/mod.rs @@ -73,23 +73,14 @@ pub fn module_globals( // written-free test can't fire on it), so plain user `const`s are unaffected. let mut top: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); - // Top-level bindings whose initializer is a NON-LITERAL (a call/member/etc. that - // yields a RUNTIME value with identity — e.g. `const c = atomic.i64_new(0)`). A - // literal init (`let p = "hi-"`) is re-materializable / by-value-capturable, so a - // read-only closure capture handles it WITHOUT a cell; a runtime-value init read - // from a plain function cannot be re-materialized and MUST be a shared cell. - let mut runtime_init: HashSet = HashSet::new(); for s in &main.body { - let (name, init) = match s { - HirStmt::Let { name, init, .. } => (name, init.as_ref()), - HirStmt::Const { name, init, .. } => (name, Some(init)), + let name = match s { + HirStmt::Let { name, .. } => name, + HirStmt::Const { name, .. } => name, _ => continue, }; if seen.insert(name.clone()) { top.push(name.clone()); - if init.is_some_and(|e| !matches!(e.kind, HirExprKind::Lit(_))) { - runtime_init.insert(name.clone()); - } } } if top.is_empty() { @@ -127,9 +118,18 @@ pub fn module_globals( // it is READ free from a function AND its init is a runtime value (the // read-only-shared-handle case, `const c = atomic.i64_new(0)`). A read-only // LITERAL is left to the by-value closure-capture path (don't disturb it). + // Promote a top-level `let`/`const` to a runtime CELL when it is WRITTEN free + // from a function (#195 mutable case), force-promoted, OR simply READ free from + // a function. The read case now covers LITERAL inits too (`const NEAR = 0.05` + // used inside a plain top-level `function`): a plain function has no closure env + // to capture into, so the by-value capture path bailed ("not a simple local"). + // A shared cell is also the JS-correct semantics (closures see later mutations), + // and a never-written literal const lands in `immutable_gcells` → read once at + // entry, so the cell cost is amortized. Top-level-only consts (never read from a + // function) stay off this path — plain user consts are unaffected. let promote = written_free.contains(&name) || force.contains(&name) - || (read_free.contains(&name) && runtime_init.contains(&name)); + || read_free.contains(&name); if promote { map.insert(name, id); id += 1; diff --git a/crates/rts-codegen-new/src/front/run/lower.rs b/crates/rts-codegen-new/src/front/run/lower.rs index f4120402..fdbcc45f 100644 --- a/crates/rts-codegen-new/src/front/run/lower.rs +++ b/crates/rts-codegen-new/src/front/run/lower.rs @@ -362,6 +362,14 @@ pub(crate) struct Lowerer<'a, 'b, 'c> { /// inside ANY function declares a fresh LOCAL that shadows the global /// (without this a prelude-internal `let out` clobbered a user gcell). pub is_main: bool, + /// Lexical block nesting depth while lowering (0 = not yet in the body; + /// `lower_block` bumps it, so the function's OUTERMOST body statements are at + /// depth 1 and anything inside a loop/if/try/block is ≥2). In `__rtsn_main` + /// the promoted-global GCELL divert of a `let`/`const` must apply ONLY to the + /// top-level declaration (depth 1) — a same-spelled `let` NESTED in a loop is + /// a fresh block-scoped local that must SHADOW the global, not alias its cell + /// (otherwise a loop-body `const x` corrupts a top-level `x` across scopes). + pub block_depth: u32, /// The NAME of the function being lowered (`__rtsn_main`, `__rtsn_ctor_C`, /// `__rtsn_method_C_m`, `__rtsn_static_C_m`, …). Drives the TS access-surface /// re-checks: a `readonly` field write is allowed only inside a @@ -468,6 +476,7 @@ impl<'a, 'b, 'c> Lowerer<'a, 'b, 'c> { classes, is_prelude, is_main: func.name == "__rtsn_main", + block_depth: 0, current_fn: func.name.clone(), builtins, float_promoted: super::floatscan::float_promoted_locals(&func.body), @@ -714,6 +723,20 @@ impl<'a, 'b, 'c> Lowerer<'a, 'b, 'c> { &mut self, module: &mut dyn Module, stmts: &[HirStmt], + ) -> FrontResult<()> { + // Track lexical nesting: the function body's own `lower_block` makes its + // direct statements depth 1; a nested loop/if/try/block body is ≥2. Used by + // `lower_let` to only divert a TOP-LEVEL (depth-1) main `let` to its gcell. + self.block_depth += 1; + let r = self.lower_block_inner(module, stmts); + self.block_depth -= 1; + r + } + + fn lower_block_inner( + &mut self, + module: &mut dyn Module, + stmts: &[HirStmt], ) -> FrontResult<()> { for s in stmts { if self.block_terminated { diff --git a/crates/rts-codegen-new/src/front/run/mod.rs b/crates/rts-codegen-new/src/front/run/mod.rs index fc7de2d0..fe518e9c 100644 --- a/crates/rts-codegen-new/src/front/run/mod.rs +++ b/crates/rts-codegen-new/src/front/run/mod.rs @@ -441,17 +441,32 @@ fn merge_programs( classes.insert(desc.clone()); } + // funcs: prelude first, then user — but the user SHADOWS the prelude by name. + // A user `class Transform` synthesizes `__rtsn_ctor_Transform` etc.; the prelude + // (node:stream) already defines a `Transform extends Duplex` under the SAME + // symbol. A blind append left BOTH ctors compiled: prune kept the prelude's + // (its name matched the user's referenced class) but dropped its now-unused + // `Duplex` super — so the prelude ctor compiled a call to a pruned function + // (`unknown function __rtsn_ctor_Duplex`). Dropping every prelude func the user + // redefines makes the user's definition win cleanly and lets prune reach the + // real dependency set. This is why user class/fn names no longer collide with + // embedded shims (Transform/Duplex/Readable/…). + let user_fn_names: std::collections::HashSet = + user.funcs.iter().map(|f| f.name.clone()).collect(); + let mut funcs: Vec = prelude + .funcs + .into_iter() + .filter(|f| !user_fn_names.contains(&f.name)) + .collect(); + // PRIVACY GATE: record which function names came from the PRELUDE (the engine's // embedded includes). The PRIVATE `engine` global is resolvable ONLY from these // functions; a user function naming `engine.*` bails explicitly (see - // `engineobj`). The prelude's own classes/methods/arrows are all in - // `prelude.funcs`, so their names are exactly this set (the user's `__rtsn_main` - // and user functions are NOT included — `merge_programs` keeps `user.main`). + // `engineobj`). Computed over the SURVIVING prelude funcs (those the user did not + // shadow), so a user-redefined name is NOT granted prelude privacy. let prelude_fns: std::collections::HashSet = - prelude.funcs.iter().map(|f| f.name.clone()).collect(); + funcs.iter().map(|f| f.name.clone()).collect(); - // funcs: prelude first, then user (user appended; last-wins on name collision). - let mut funcs = prelude.funcs; funcs.extend(user.funcs); // fn_this_class + captures: prelude then user. diff --git a/crates/rts-codegen-new/src/front/run/stmt_let.rs b/crates/rts-codegen-new/src/front/run/stmt_let.rs index c39947b2..bac6910d 100644 --- a/crates/rts-codegen-new/src/front/run/stmt_let.rs +++ b/crates/rts-codegen-new/src/front/run/stmt_let.rs @@ -33,7 +33,7 @@ impl<'a, 'b, 'c> Lowerer<'a, 'b, 'c> { types::I64, crate::value::PolyValue::undefined().raw() as i64, ); - if self.is_main { + if self.is_main && self.block_depth == 1 { if let Some(id) = self.gcells.get(name).copied() { self.emit_gcell_set(module, id, undef)?; return Ok(()); @@ -59,7 +59,11 @@ impl<'a, 'b, 'c> Lowerer<'a, 'b, 'c> { // fresh LOCAL that shadows the global (JS scoping) — routing it to the cell // made the prelude's `Console.__format` local `let out` CLOBBER a user // gcell named `out` (every console.log corrupted the captured variable). - if self.is_main { + // Only the TOP-LEVEL (depth-1) main declaration initializes the promoted + // gcell. A same-spelled `let`/`const` NESTED in a loop/if/block is a fresh + // block-scoped local that SHADOWS the global (JS scoping) — diverting it to + // the cell here made a loop-body `const x` alias and corrupt a top-level `x`. + if self.is_main && self.block_depth == 1 { if let Some(id) = self.gcells.get(name).copied() { let val = self.lower_expr(module, init)?; let word = self.box_value(val); diff --git a/crates/rts-egui/src/frame/gpu.rs b/crates/rts-egui/src/frame/gpu.rs index 10103fa6..3f235a1b 100644 --- a/crates/rts-egui/src/frame/gpu.rs +++ b/crates/rts-egui/src/frame/gpu.rs @@ -193,10 +193,9 @@ pub struct RenderState { /// Janela transparente → clear com alpha 0 (o painel egui também fica /// transparente em `end_frame`), pra o SO compor o fundo. pub transparent: bool, - /// Estado do scene pass 3D (`gpu3d`) desta janela — `None` até o 1º - /// `gpu3d.mesh`. Quando há draws no frame, a cena é gravada ANTES do pass - /// do egui (que então carrega em vez de limpar). Ver `crate::scene3d`. - pub scene: Option, + /// Pipeline 3D wgpu (scene pass) — `None` até o TS usar `egui.mesh*`/`drawMesh`. + /// Quando presente, roda ANTES do egui (limpa color+depth); o egui usa `Load`. + pub scene: Option, } impl RenderState { @@ -354,22 +353,26 @@ pub(crate) fn present_wgpu( r.renderer .update_buffers(&r.device, &r.queue, &mut encoder, &paint_jobs, &screen_descriptor); - // ── Scene pass 3D (gpu3d) — ANTES do pass do egui, no MESMO encoder ───── - // Quando o TS enfileirou `gpu3d.draw` neste frame, a cena limpa cor+depth e - // desenha as malhas; o pass do egui abaixo então CARREGA (LoadOp::Load) em - // vez de limpar, compondo a UI por cima. Sem draws: comportamento idêntico - // ao anterior (egui limpa). Ver docs/specs/gpu3d-scene-pass.md. - let scene_drawn = crate::scene3d::record_if_active( - &mut r.scene, - &r.device, - &r.queue, - &mut encoder, - &view, - r.config.width, - r.config.height, - ); + // ── SCENE PASS 3D (antes do egui, mesmo encoder) ───────────────────────── + // Se há um pipeline 3D ativo, ele limpa color+depth e desenha as meshes; o + // egui então compõe por cima com LoadOp::Load. Sem cena, o egui limpa (Clear), + // preservando o comportamento antigo das janelas puramente de UI. + let scene_cleared = if let Some(scene) = &mut r.scene { + scene.render(&r.device, &r.queue, &mut encoder, &view, r.config.width, r.config.height) + } else { + false + }; { + let egui_load = if scene_cleared { + wgpu::LoadOp::Load + } else { + wgpu::LoadOp::Clear(if r.transparent { + wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } + } else { + wgpu::Color { r: 0.02, g: 0.02, b: 0.03, a: 1.0 } + }) + }; let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("rts-egui pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -377,16 +380,7 @@ pub(crate) fn present_wgpu( resolve_target: None, depth_slice: None, ops: wgpu::Operations { - // Cena 3D desenhada → carrega (a UI compõe por cima). - // Transparente: clear com alpha 0 p/ o SO compor o fundo. - // Opaco: fundo escuro padrão. - load: if scene_drawn { - wgpu::LoadOp::Load - } else if r.transparent { - wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }) - } else { - wgpu::LoadOp::Clear(wgpu::Color { r: 0.02, g: 0.02, b: 0.03, a: 1.0 }) - }, + load: egui_load, store: wgpu::StoreOp::Store, }, })], diff --git a/crates/rts-egui/src/frame/mod.rs b/crates/rts-egui/src/frame/mod.rs index bf3f9138..103c0d39 100644 --- a/crates/rts-egui/src/frame/mod.rs +++ b/crates/rts-egui/src/frame/mod.rs @@ -7,6 +7,7 @@ //! tesselará as shapes e renderiza/apresenta o frame via o backend ativo. mod gpu; +pub(crate) mod scene3d; mod render; // API pública do módulo `frame` (mantém o `pub use frame::*` do lib.rs): os tipos @@ -135,15 +136,15 @@ fn finish_frame(c: &mut crate::ctx::UiCtx, cmds: Vec) { // (`body { margin/padding }`), não do egui — senão há espaçamento duplo e // o DOM não controla o layout (a "borda à esquerda" que aparecia era a // `inner_margin` padrão do tema). O DOM/CSS é o dono do espaçamento. - // Transparente → sem fundo (Frame::NONE); opaco → fundo do tema mas margem 0. - // Cena 3D ativa (gpu3d.draw neste frame) → também SEM fundo: o painel - // opaco taparia o scene pass que o present grava por baixo do egui. - let scene_active = match &c.backend { - Backend::Wgpu(r) => r.scene.as_ref().is_some_and(|s| !s.draws.is_empty()), - #[cfg(feature = "glow-backend")] - Backend::Glow(_) => false, + // Transparente OU com CENA 3D ativa (scene pass já pintou o fundo/geometria) + // → sem fundo (Frame::NONE) pra o 3D aparecer; caso contrário opaco (fundo + // do tema, margem 0). Sem isto o CentralPanel taparia o scene pass. + let has_scene = if let crate::frame::Backend::Wgpu(r) = &c.backend { + r.scene.is_some() + } else { + false }; - let panel = if c.transparent || scene_active { + let panel = if c.transparent || has_scene { egui::CentralPanel::default().frame(egui::Frame::NONE) } else { let bg = c.egui_ctx.style().visuals.panel_fill; diff --git a/crates/rts-egui/src/frame/scene3d.rs b/crates/rts-egui/src/frame/scene3d.rs new file mode 100644 index 00000000..81e9d0b6 --- /dev/null +++ b/crates/rts-egui/src/frame/scene3d.rs @@ -0,0 +1,948 @@ +//! Pipeline 3D wgpu (scene pass) — desenha meshes com câmera/luz/depth ANTES do +//! egui no mesmo frame (`custom3d_wgpu`, sancionado em egui-ui-crate-design §1b). +//! +//! Fica no braço `Backend::Wgpu` (só-wgpu; glow não tem 3D — degrada, não panica). +//! A trait neutra `rts-render::Renderer` NÃO ganha nada disto (é backend-neutra); +//! estas capacidades vivem no namespace `egui`, tied ao handle da janela wgpu. +//! +//! Fluxo: TS chama `egui.mesh_upload` (1×, sobe vbuf/ibuf pra VRAM), depois por +//! frame `egui.set_camera` + `egui.set_light` + `egui.draw_mesh` (enfileira). No +//! `present_wgpu`, `render()` limpa color+depth, desenha a fila e a esvazia; o +//! egui pinta por cima com `LoadOp::Load`. + +use std::collections::HashMap; + +// cast de fatia → bytes (evita depender de bytemuck/wgpu-util). +fn f32_bytes(s: &[f32]) -> &[u8] { + unsafe { std::slice::from_raw_parts(s.as_ptr() as *const u8, std::mem::size_of_val(s)) } +} +fn u32_bytes(s: &[u32]) -> &[u8] { + unsafe { std::slice::from_raw_parts(s.as_ptr() as *const u8, std::mem::size_of_val(s)) } +} +// cria um buffer já preenchido (mapped_at_creation) — sem wgpu::util. +fn init_buffer(device: &wgpu::Device, label: &str, data: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size: data.len() as u64, + usage, + mapped_at_creation: true, + }); + { let mut mv = buf.slice(..).get_mapped_range_mut(); mv.copy_from_slice(data); } + buf.unmap(); + buf +} + +/// WGSL: vertex = pos+normal (slot 0) × instância model(4×vec4)+color (slot 1). +/// Uniform (group 0): viewProj + luz (xyz=dir, w=ambiente). Shading difuso. +const SHADER: &str = r#" +struct Cam { + view_proj: mat4x4, + light: vec4, + cam_pos: vec4, + cam_right: vec4, // xyz = right, w = tanH + cam_up: vec4, // xyz = up, w = tanV + cam_fwd: vec4, // xyz = forward + light_vp: mat4x4, // view·proj da LUZ (shadow map) +}; +@group(0) @binding(0) var cam: Cam; +// shadow map (group 1): depth da cena vista da luz + comparison sampler +@group(1) @binding(0) var shadow_tex: texture_depth_2d; +@group(1) @binding(1) var shadow_samp: sampler_comparison; + +// vertex do SHADOW PASS: projeta pela luz (só posição). +@vertex +fn shadow_vs( + @location(0) position: vec3, + @location(2) m0: vec4, + @location(3) m1: vec4, + @location(4) m2: vec4, + @location(5) m3: vec4, +) -> @builtin(position) vec4 { + let model = mat4x4(m0, m1, m2, m3); + return cam.light_vp * (model * vec4(position, 1.0)); +} + +// fator de sombra (1 = iluminado, 0 = na sombra) via PCF 3×3. +fn shadow_factor(world: vec3) -> f32 { + let lc = cam.light_vp * vec4(world, 1.0); + let proj = lc.xyz / lc.w; + let uv = proj.xy * vec2(0.5, -0.5) + vec2(0.5, 0.5); + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || proj.z > 1.0) { return 1.0; } + let d = proj.z - 0.0015; // bias contra acne + let texel = 1.0 / 2048.0; + var sum = 0.0; + for (var oy = -1; oy <= 1; oy = oy + 1) { + for (var ox = -1; ox <= 1; ox = ox + 1) { + let o = vec2(f32(ox), f32(oy)) * texel; + sum = sum + textureSampleCompare(shadow_tex, shadow_samp, uv + o, d); + } + } + return sum / 9.0; +} + +// ── SKYBOX: triângulo fullscreen; gradiente + estrelas por DIREÇÃO de mundo +// (giram junto com a câmera). Depth write off (fica no fundo). ────────────── +struct SkyOut { @builtin(position) clip: vec4, @location(0) ndc: vec2 }; +@vertex +fn sky_vs(@builtin(vertex_index) vi: u32) -> SkyOut { + var p = array, 3>(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + var o: SkyOut; + o.clip = vec4(p[vi], 1.0, 1.0); + o.ndc = p[vi]; + return o; +} +fn hash13(p3: vec3) -> f32 { + var q = fract(p3 * 0.1031); + q = q + dot(q, q.yzx + 33.33); + return fract((q.x + q.y) * q.z); +} +@fragment +fn sky_fs(i: SkyOut) -> @location(0) vec4 { + let ray = normalize(cam.cam_fwd.xyz + + cam.cam_right.xyz * (i.ndc.x * cam.cam_right.w) + + cam.cam_up.xyz * (i.ndc.y * cam.cam_up.w)); + let t = clamp(ray.y * 0.5 + 0.5, 0.0, 1.0); + var col = mix(vec3(0.02, 0.02, 0.035), vec3(0.01, 0.015, 0.05), t); + // estrelas: hash da direção quantizada (esparsas e brilhantes) + let h = hash13(floor(ray * 260.0)); + if (h > 0.9915) { let s = (h - 0.9915) * 110.0; col = col + vec3(s, s, s); } + return vec4(col, 1.0); +} + +struct VOut { + @builtin(position) clip: vec4, + @location(0) normal: vec3, + @location(1) color: vec4, + @location(2) world: vec3, + @location(3) emissive: f32, + @location(4) tex: f32, +}; + +@vertex +fn vs( + @location(0) position: vec3, + @location(1) normal: vec3, + @location(2) m0: vec4, + @location(3) m1: vec4, + @location(4) m2: vec4, + @location(5) m3: vec4, + @location(6) color: vec4, + @location(7) iparams: vec4, +) -> VOut { + let model = mat4x4(m0, m1, m2, m3); + let world = model * vec4(position, 1.0); + var o: VOut; + o.clip = cam.view_proj * world; + o.normal = normalize((model * vec4(normal, 0.0)).xyz); + o.color = color; + o.world = world.xyz; + o.emissive = iparams.x; + o.tex = iparams.y; + return o; +} + +@fragment +fn fs(i: VOut) -> @location(0) vec4 { + var albedo = i.color.rgb; + // TEXTURA procedural: xadrez em espaço de mundo (iparams.y = i.tex) + if (i.tex > 0.5) { + let s = 1.0; + let c = floor(i.world.x * s) + floor(i.world.z * s) + floor(i.world.y * s); + let chk = fract(c * 0.5) * 2.0; // 0 ou 1 + albedo = albedo * mix(0.5, 1.0, chk); + } + // emissivo (ex.: o Sol) — cor cheia, sem sombreamento + if (i.emissive > 0.5) { return vec4(albedo, i.color.a); } + let n = normalize(i.normal); + // LUZ PONTUAL: cam.light.xyz = POSICAO da luz (ex.: o Sol) + let l = normalize(cam.light.xyz - i.world); + let nd = max(dot(n, l), 0.0); + let sh = shadow_factor(i.world); // 1 = iluminado, 0 = na sombra + let lit = cam.light.w + (1.0 - cam.light.w) * nd * sh; + let vdir = normalize(cam.cam_pos.xyz - i.world); + let h = normalize(l + vdir); + let spec = pow(max(dot(n, h), 0.0), 32.0) * 0.3 * sh; + let rgb = albedo * lit + vec3(spec, spec, spec); + return vec4(rgb, i.color.a); +} +"#; + +const SHADOW_SIZE: u32 = 2048; + +const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; + +struct GpuMesh { + vbuf: wgpu::Buffer, + ibuf: wgpu::Buffer, + icount: u32, +} + +pub struct Scene3D { + pipeline: wgpu::RenderPipeline, + sky_pipeline: wgpu::RenderPipeline, + shadow_pipeline: wgpu::RenderPipeline, + cam_buf: wgpu::Buffer, + cam_bg: wgpu::BindGroup, + shadow_view: wgpu::TextureView, + shadow_bg: wgpu::BindGroup, + depth_view: wgpu::TextureView, + depth_w: u32, + depth_h: u32, + meshes: HashMap, + next_mesh: u64, + // estado por-frame + view_proj: [f32; 16], + light: [f32; 4], + light_vp: [f32; 16], // view·proj da luz (shadow map); identidade = sem sombra + cam_pos: [f32; 3], + cright: [f32; 3], + cup: [f32; 3], + cfwd: [f32; 3], + tan_h: f32, + tan_v: f32, + draws: Vec<(u64, [f32; 16], [f32; 4], f32, f32)>, + inst_buf: wgpu::Buffer, + inst_cap: u64, + /// Fundo do scene pass: `None` = skybox procedural (default); `Some(rgba)` = + /// cor CHAPADA (o viewport do editor quer um fundo neutro, não o starfield). + bg: Option<[f32; 4]>, +} + +impl Scene3D { + pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Scene3D { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("scene3d shader"), + source: wgpu::ShaderSource::Wgsl(SHADER.into()), + }); + + // uniform: view_proj(16) + light(4) + cam_pos(4) + right(4) + up(4) + fwd(4) + // + light_vp(16) = 52 f32 = 208 bytes + let cam_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene3d cam"), + size: 208, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let cam_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("scene3d cam bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let cam_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("scene3d cam bg"), + layout: &cam_bgl, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: cam_buf.as_entire_binding(), + }], + }); + + // shadow map: textura depth 2048² (render target + amostrada) + comparison sampler + let (shadow_view, _st) = make_shadow(device, SHADOW_SIZE); + let shadow_samp = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("scene3d shadow samp"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + compare: Some(wgpu::CompareFunction::LessEqual), + ..Default::default() + }); + let shadow_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("scene3d shadow bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), + count: None, + }, + ], + }); + let shadow_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("scene3d shadow bg"), + layout: &shadow_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&shadow_view) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&shadow_samp) }, + ], + }); + + // layout do pass principal + sky: group 0 (câmera) + group 1 (shadow map) + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene3d layout"), + bind_group_layouts: &[Some(&cam_bgl), Some(&shadow_bgl)], + immediate_size: 0, + }); + // layout do shadow pass: só group 0 (light_vp vem do uniform da câmera) + let cam_only_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("scene3d shadow layout"), + bind_group_layouts: &[Some(&cam_bgl)], + immediate_size: 0, + }); + + // vertex (slot 0): pos vec3 @0, normal vec3 @12 — stride 24 + let vbl = wgpu::VertexBufferLayout { + array_stride: 24, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 0, shader_location: 0 }, + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 12, shader_location: 1 }, + ], + }; + // instância (slot 1): model 4×vec4 @2..5, color vec4 @6 — stride 80 + let ibl = wgpu::VertexBufferLayout { + array_stride: 96, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 0, shader_location: 2 }, + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 16, shader_location: 3 }, + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 32, shader_location: 4 }, + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 48, shader_location: 5 }, + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 64, shader_location: 6 }, + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 80, shader_location: 7 }, + ], + }; + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("scene3d pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + buffers: &[vbl.clone(), ibl.clone()], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + // sem backface culling: lajes finas / beiral de telhado / escala + // não-uniforme não "vazam" ao serem vistos por trás (editor). + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multiview_mask: None, + multisample: wgpu::MultisampleState::default(), + cache: None, + }); + + // pipeline da SKYBOX: triângulo fullscreen, sem depth write (fica no fundo). + let sky_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("scene3d sky pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("sky_vs"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("sky_fs"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multiview_mask: None, + multisample: wgpu::MultisampleState::default(), + cache: None, + }); + + // SHADOW PIPELINE: depth-only, projeta pela luz (só group 0). Bias contra acne. + let shadow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("scene3d shadow pipeline"), + layout: Some(&cam_only_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("shadow_vs"), + buffers: &[vbl, ibl], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: None, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState { constant: 2, slope_scale: 2.0, clamp: 0.0 }, + }), + multiview_mask: None, + multisample: wgpu::MultisampleState::default(), + cache: None, + }); + + let (depth_view, _t) = make_depth(device, 1, 1); + let inst_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene3d inst"), + size: 96 * 64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + Scene3D { + pipeline, + sky_pipeline, + shadow_pipeline, + cam_buf, + cam_bg, + shadow_view, + shadow_bg, + depth_view, + depth_w: 1, + depth_h: 1, + meshes: HashMap::new(), + next_mesh: 1, + view_proj: identity(), + light: [0.4, 0.8, 0.4, 0.25], + light_vp: identity(), + cam_pos: [0.0, 0.0, 0.0], + cright: [1.0, 0.0, 0.0], + cup: [0.0, 1.0, 0.0], + cfwd: [0.0, 0.0, 1.0], + tan_h: 1.0, + tan_v: 1.0, + draws: Vec::new(), + inst_buf, + inst_cap: 64, + bg: None, + } + } + + pub fn upload_mesh(&mut self, device: &wgpu::Device, verts: &[f32], indices: &[u32]) -> u64 { + let vbuf = init_buffer(device, "scene3d vbuf", f32_bytes(verts), wgpu::BufferUsages::VERTEX); + let ibuf = init_buffer(device, "scene3d ibuf", u32_bytes(indices), wgpu::BufferUsages::INDEX); + let id = self.next_mesh; + self.next_mesh += 1; + self.meshes.insert(id, GpuMesh { vbuf, ibuf, icount: indices.len() as u32 }); + id + } + + pub fn free_mesh(&mut self, id: u64) { + self.meshes.remove(&id); + } + + pub fn set_camera(&mut self, cd: Cam3D) { + self.view_proj = cd.view_proj; + self.cam_pos = cd.cam_pos; + self.cright = cd.right; + self.cup = cd.up; + self.cfwd = cd.fwd; + self.tan_h = cd.tan_h; + self.tan_v = cd.tan_v; + } + pub fn set_light(&mut self, d: [f32; 3], ambient: f32) { + // ponto de luz: guarda a POSICAO (nao normaliza) + self.light = [d[0], d[1], d[2], ambient]; + } + /// Configura o shadow map: direção da luz (para onde a luz VIAJA) + centro/raio + /// da caixa ortográfica que o shadow map cobre. radius<=0 desliga a sombra. + pub fn set_shadow(&mut self, dir: [f32; 3], center: [f32; 3], radius: f32) { + if radius <= 0.0 { + self.light_vp = identity(); + } else { + self.light_vp = light_view_proj(dir, center, radius); + } + } + pub fn queue_draw(&mut self, mesh: u64, model: [f32; 16], color: [f32; 4], emissive: f32, tex: f32) { + self.draws.push((mesh, model, color, emissive, tex)); + } + /// Fundo CHAPADO (desliga o skybox): o pass limpa o color pra `rgba` e não + /// desenha o starfield. Ideal pro viewport do editor. + pub fn set_clear_color(&mut self, rgba: [f32; 4]) { + self.bg = Some(rgba); + } + /// Religa (on) ou mantém desligado o skybox procedural. `on` volta a `bg=None`. + pub fn set_skybox(&mut self, on: bool) { + if on { + self.bg = None; + } + } + + fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) { + if w != self.depth_w || h != self.depth_h { + let (v, _t) = make_depth(device, w.max(1), h.max(1)); + self.depth_view = v; + self.depth_w = w; + self.depth_h = h; + } + } + + /// Roda o scene pass no `encoder` compartilhado: limpa color(bg)+depth, desenha + /// a fila e a esvazia. Retorna `true` se limpou o color (o egui deve usar Load). + pub fn render( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + view: &wgpu::TextureView, + w: u32, + h: u32, + ) -> bool { + self.ensure_depth(device, w, h); + + // uniform: view_proj(16) + light(4) + cam_pos(4) + right(4) + up(4) + fwd(4) + // + light_vp(16) = 52 f32 + let mut cam = [0f32; 52]; + cam[..16].copy_from_slice(&self.view_proj); + cam[16..20].copy_from_slice(&self.light); + cam[20..23].copy_from_slice(&self.cam_pos); + cam[24..27].copy_from_slice(&self.cright); + cam[27] = self.tan_h; + cam[28..31].copy_from_slice(&self.cup); + cam[31] = self.tan_v; + cam[32..35].copy_from_slice(&self.cfwd); + cam[36..52].copy_from_slice(&self.light_vp); + queue.write_buffer(&self.cam_buf, 0, f32_bytes(&cam)); + + // instâncias + let n = self.draws.len() as u64; + if n > self.inst_cap { + let cap = n.next_power_of_two().max(64); + self.inst_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("scene3d inst"), + size: 96 * cap, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.inst_cap = cap; + } + let mut inst: Vec = Vec::with_capacity(self.draws.len() * 24); + for (_m, model, color, emissive, tex) in &self.draws { + inst.extend_from_slice(model); + inst.extend_from_slice(color); + inst.push(*emissive); + inst.push(*tex); + inst.push(0.0); + inst.push(0.0); + } + if !inst.is_empty() { + queue.write_buffer(&self.inst_buf, 0, f32_bytes(&inst)); + } + + // ── SHADOW PASS: depth da cena vista da luz (só quando há sombra ativa) ── + let has_shadow = self.light_vp != identity(); + if has_shadow && !self.draws.is_empty() { + let mut sp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("scene3d shadow pass"), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.shadow_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + sp.set_pipeline(&self.shadow_pipeline); + sp.set_bind_group(0, &self.cam_bg, &[]); + for (i, (mesh_id, _model, _color, _emiss, _tex)) in self.draws.iter().enumerate() { + if let Some(m) = self.meshes.get(mesh_id) { + let off = (i as u64) * 96; + sp.set_vertex_buffer(0, m.vbuf.slice(..)); + sp.set_vertex_buffer(1, self.inst_buf.slice(off..off + 96)); + sp.set_index_buffer(m.ibuf.slice(..), wgpu::IndexFormat::Uint32); + sp.draw_indexed(0..m.icount, 0, 0..1); + } + } + } + + // Cor de clear: fundo chapado (`bg`) OU o escuro padrão sob o skybox. + let clear = match self.bg { + Some(c) => wgpu::Color { r: c[0] as f64, g: c[1] as f64, b: c[2] as f64, a: c[3] as f64 }, + None => wgpu::Color { r: 0.02, g: 0.02, b: 0.03, a: 1.0 }, + }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("scene3d pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(clear), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + pass.set_bind_group(0, &self.cam_bg, &[]); + pass.set_bind_group(1, &self.shadow_bg, &[]); + // 1. SKYBOX (fullscreen, sem depth write) — fica no fundo. Pulado quando há + // fundo chapado (`bg`), que o clear acima já pintou. + if self.bg.is_none() { + pass.set_pipeline(&self.sky_pipeline); + pass.draw(0..3, 0..1); + } + // 2. meshes (depth test/write) + pass.set_pipeline(&self.pipeline); + for (i, (mesh_id, _model, _color, _emiss, _tex)) in self.draws.iter().enumerate() { + if let Some(m) = self.meshes.get(mesh_id) { + let off = (i as u64) * 96; + pass.set_vertex_buffer(0, m.vbuf.slice(..)); + pass.set_vertex_buffer(1, self.inst_buf.slice(off..off + 96)); + pass.set_index_buffer(m.ibuf.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..m.icount, 0, 0..1); + } + } + drop(pass); + + self.draws.clear(); + true + } +} + +fn make_depth(device: &wgpu::Device, w: u32, h: u32) -> (wgpu::TextureView, wgpu::Texture) { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("scene3d 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: DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + (view, tex) +} + +fn make_shadow(device: &wgpu::Device, size: u32) -> (wgpu::TextureView, wgpu::Texture) { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("scene3d shadow map"), + size: wgpu::Extent3d { width: size, height: size, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + (view, tex) +} + +fn identity() -> [f32; 16] { + [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] +} + +/// view·proj ORTOGRÁFICA da luz direcional (shadow map). `dir` = direção que a luz +/// viaja; a câmera-luz é posta em `center - dir*2r` olhando na direção `dir`; ortho +/// meio-extent = radius, depth 0..1 (convenção wgpu). Column-major. +fn light_view_proj(dir: [f32; 3], center: [f32; 3], radius: f32) -> [f32; 16] { + let mut f = dir; + let fl = (f[0] * f[0] + f[1] * f[1] + f[2] * f[2]).sqrt().max(1e-6); + f = [f[0] / fl, f[1] / fl, f[2] / fl]; + // up de referência; se quase paralelo a f, troca por (1,0,0) + let up0 = if f[1].abs() > 0.99 { [1.0f32, 0.0, 0.0] } else { [0.0f32, 1.0, 0.0] }; + // right = normalize(up0 × f); up = f × right + let mut r = cross(up0, f); + let rl = (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt().max(1e-6); + r = [r[0] / rl, r[1] / rl, r[2] / rl]; + let u = cross(f, r); + let eye = [center[0] - f[0] * 2.0 * radius, center[1] - f[1] * 2.0 * radius, center[2] - f[2] * 2.0 * radius]; + let tx = -(r[0] * eye[0] + r[1] * eye[1] + r[2] * eye[2]); + let ty = -(u[0] * eye[0] + u[1] * eye[1] + u[2] * eye[2]); + let tz = -(f[0] * eye[0] + f[1] * eye[1] + f[2] * eye[2]); + let v = [ + r[0], u[0], f[0], 0.0, + r[1], u[1], f[1], 0.0, + r[2], u[2], f[2], 0.0, + tx, ty, tz, 1.0, + ]; + let near = 0.05f32; + let far = 4.0 * radius; + let inv = 1.0 / radius; + let dz = 1.0 / (far - near); + let p = [ + inv, 0.0, 0.0, 0.0, + 0.0, inv, 0.0, 0.0, + 0.0, 0.0, dz, 0.0, + 0.0, 0.0, -near * dz, 1.0, + ]; + mul(&p, &v) +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +/// view·proj (column-major) a partir de câmera fly (yaw/pitch) — MESMA convenção +/// do rasterizador software: right=(cyw,0,-syw), up=(-syw*spt,cpt,-cyw*spt), +/// forward=(syw*cpt,spt,cyw*cpt); proj perspectiva left-handed (z forward, depth 0..1). +/// Dados de câmera pro uniform 3D: viewProj + posição + base (right/up/fwd) + +/// tangentes do FOV (pro raio da skybox). +pub struct Cam3D { + pub view_proj: [f32; 16], + pub cam_pos: [f32; 3], + pub right: [f32; 3], + pub up: [f32; 3], + pub fwd: [f32; 3], + pub tan_h: f32, + pub tan_v: f32, +} + +pub fn view_proj( + camx: f32, camy: f32, camz: f32, yaw: f32, pitch: f32, fov_y: f32, aspect: f32, +) -> Cam3D { + let (cyw, syw) = (yaw.cos(), yaw.sin()); + let (cpt, spt) = (pitch.cos(), pitch.sin()); + let right = [cyw, 0.0, -syw]; + let up = [-syw * spt, cpt, -cyw * spt]; + let fwd = [syw * cpt, spt, cyw * cpt]; + let tx = -(right[0] * camx + right[1] * camy + right[2] * camz); + let ty = -(up[0] * camx + up[1] * camy + up[2] * camz); + let tz = -(fwd[0] * camx + fwd[1] * camy + fwd[2] * camz); + let v = [ + right[0], up[0], fwd[0], 0.0, + right[1], up[1], fwd[1], 0.0, + right[2], up[2], fwd[2], 0.0, + tx, ty, tz, 1.0, + ]; + let (p, tan_v) = perspective_lh(fov_y, aspect, 0.1, 500.0); + Cam3D { + view_proj: mul(&p, &v), + cam_pos: [camx, camy, camz], + right, + up, + fwd, + tan_h: tan_v * aspect, + tan_v, + } +} + +/// Projeção perspectiva left-handed (z forward, depth 0..1 — convenção wgpu/DX), +/// column-major. `fov_y` em radianos, `aspect` = largura/altura. Compartilhada +/// entre a câmera fly (`view_proj`) e a look-at. +fn perspective_lh(fov_y: f32, aspect: f32, near: f32, far: f32) -> ([f32; 16], f32) { + let tan_v = (fov_y * 0.5).tan(); + let f = 1.0 / tan_v; + let p = [ + f / aspect, 0.0, 0.0, 0.0, + 0.0, f, 0.0, 0.0, + 0.0, 0.0, far / (far - near), 1.0, + 0.0, 0.0, -(far * near) / (far - near), 0.0, + ]; + (p, tan_v) +} + +/// Câmera LOOK-AT NaN-safe (`eye` olhando pra `target`, up de referência +Y) com +/// `near`/`far` explícitos — mais robusta que yaw/pitch pro "frame selected" do +/// editor: quando a direção fica ~paralela ao up (olhar reto pra cima/baixo) usa +/// um up alternativo (+Z) em vez de gerar NaN por gimbal. Mesma base LH de +/// `view_proj` (right/up/fwd ortonormais), então skybox/shading seguem casando. +pub fn view_proj_lookat( + eye: [f32; 3], target: [f32; 3], fov_y: f32, aspect: f32, near: f32, far: f32, +) -> Cam3D { + let mut fwd = v_norm(v_sub(target, eye)); + if fwd == [0.0, 0.0, 0.0] { + fwd = [0.0, 0.0, 1.0]; // eye≈target: direção default em vez de zero/NaN + } + // right = worldUp × fwd; se degenerado (fwd ~paralelo a +Y), usa +Z como up alt. + let mut right = v_cross([0.0, 1.0, 0.0], fwd); + if v_len(right) < 1e-4 { + right = v_cross([0.0, 0.0, 1.0], fwd); + } + let right = v_norm(right); + let up = v_cross(fwd, right); // já ortonormal (fwd,right unitários e ⟂) + let tx = -(right[0] * eye[0] + right[1] * eye[1] + right[2] * eye[2]); + let ty = -(up[0] * eye[0] + up[1] * eye[1] + up[2] * eye[2]); + let tz = -(fwd[0] * eye[0] + fwd[1] * eye[1] + fwd[2] * eye[2]); + let v = [ + right[0], up[0], fwd[0], 0.0, + right[1], up[1], fwd[1], 0.0, + right[2], up[2], fwd[2], 0.0, + tx, ty, tz, 1.0, + ]; + let (p, tan_v) = perspective_lh(fov_y, aspect, near, far); + Cam3D { + view_proj: mul(&p, &v), + cam_pos: eye, + right, + up, + fwd, + tan_h: tan_v * aspect, + tan_v, + } +} + +#[inline] +fn v_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +#[inline] +fn v_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} +#[inline] +fn v_len(a: [f32; 3]) -> f32 { + (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt() +} +#[inline] +fn v_norm(a: [f32; 3]) -> [f32; 3] { + let l = v_len(a); + if l < 1e-9 { [0.0, 0.0, 0.0] } else { [a[0] / l, a[1] / l, a[2] / l] } +} + +/// model = T · Ry · Rx · S (column-major) — casa com a rotação Y-depois-X do soft. +pub fn model_matrix( + px: f32, py: f32, pz: f32, rx: f32, ry: f32, sx: f32, sy: f32, sz: f32, +) -> [f32; 16] { + let (cx, sxr) = (rx.cos(), rx.sin()); + let (cy, syr) = (ry.cos(), ry.sin()); + // R = Rx · Ry (Ry aplicado primeiro), 3x3 + let r00 = cy; + let r01 = 0.0; + let r02 = syr; + let r10 = sxr * syr; + let r11 = cx; + let r12 = -sxr * cy; + let r20 = -cx * syr; + let r21 = sxr; + let r22 = cx * cy; + // model column-major: colunas escaladas por sx/sy/sz, última = translação + [ + r00 * sx, r10 * sx, r20 * sx, 0.0, + r01 * sy, r11 * sy, r21 * sy, 0.0, + r02 * sz, r12 * sz, r22 * sz, 0.0, + px, py, pz, 1.0, + ] +} + +/// a·b para matrizes 4x4 column-major. +fn mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { + let mut o = [0f32; 16]; + for c in 0..4 { + for r in 0..4 { + let mut s = 0.0; + for k in 0..4 { + s += a[k * 4 + r] * b[c * 4 + k]; + } + o[c * 4 + r] = s; + } + } + o +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Aplica a matriz column-major `m` (4×4) ao ponto homogêneo `(p,1)`. + fn apply(m: &[f32; 16], p: [f32; 3]) -> [f32; 4] { + let mut o = [0f32; 4]; + for r in 0..4 { + o[r] = m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]; + } + o + } + + /// Um ponto à FRENTE da câmera projeta dentro do clip volume: w>0 e z∈[0,w] + /// (convenção wgpu, depth 0..1 após a divisão por w). Pega erro de sinal/ + /// transposição na projeção LH. + #[test] + fn point_in_front_projects_inside_clip() { + let cam = view_proj_lookat([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], 1.0, 1.5, 0.1, 100.0); + let clip = apply(&cam.view_proj, [0.0, 0.0, 5.0]); // 5 à frente (+z) + assert!(clip[3] > 0.0, "w deve ser >0 à frente, veio {}", clip[3]); + let ndc_z = clip[2] / clip[3]; + assert!((0.0..=1.0).contains(&ndc_z), "z ndc fora de [0,1]: {ndc_z}"); + } + + /// A base look-at é ortonormal (right/up/fwd unitários e mutuamente ⟂). + #[test] + fn lookat_basis_orthonormal() { + let cam = view_proj_lookat([3.0, 2.0, -4.0], [0.0, 0.0, 0.0], 1.0, 1.0, 0.1, 100.0); + for b in [cam.right, cam.up, cam.fwd] { + assert!((v_len(b) - 1.0).abs() < 1e-4, "base não unitária: {}", v_len(b)); + } + let dot = |a: [f32; 3], b: [f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + assert!(dot(cam.right, cam.up).abs() < 1e-4); + assert!(dot(cam.right, cam.fwd).abs() < 1e-4); + assert!(dot(cam.up, cam.fwd).abs() < 1e-4); + } + + /// Olhar reto pra baixo (fwd ∥ up de referência) NÃO gera NaN — usa o up alt. + #[test] + fn lookat_straight_down_no_nan() { + let cam = view_proj_lookat([0.0, 10.0, 0.0], [0.0, 0.0, 0.0], 1.0, 1.0, 0.1, 100.0); + for c in cam.view_proj { + assert!(c.is_finite(), "view_proj tem NaN/inf olhando reto pra baixo"); + } + assert!((v_len(cam.right) - 1.0).abs() < 1e-4); + } + + /// `model_matrix` sem rotação/escala 1 é translação pura. + #[test] + fn model_translation_only() { + let m = model_matrix(2.0, -3.0, 4.0, 0.0, 0.0, 1.0, 1.0, 1.0); + let p = apply(&m, [1.0, 1.0, 1.0]); + assert_eq!([p[0], p[1], p[2]], [3.0, -2.0, 5.0]); + } +} diff --git a/crates/rts-egui/src/lib.rs b/crates/rts-egui/src/lib.rs index 9eb65b28..94b055ee 100644 --- a/crates/rts-egui/src/lib.rs +++ b/crates/rts-egui/src/lib.rs @@ -22,13 +22,13 @@ mod ctx; mod app; mod canvas; mod frame; -mod scene3d; #[cfg(feature = "glow-backend")] mod glbackend; // `pub` para a facade `rts-runtime` chamar `register_backend()` no bootstrap // (`runtime_init`), instalando o backend no thread_local da main thread no AOT. pub mod render_backend; mod widgets; +mod scene_api; // O DOM (árvore + parser + NodeId) E o ESTADO de estilo vivem no crate `rts-dom`; // o egui só os CONSOME (lê e pinta). Aliases para reuso interno: `crate::dom::*` @@ -265,8 +265,94 @@ pub fn register(e: &mut Engine) { "Width (points) of a text in the real font — the ONLY layout-aware op left in egui (TS can't measure text without the font). Synchronous.", canvas::__RTS_FN_NS_EGUI_MEASURE_TEXT as *const u8, )) + // ── Pipeline 3D wgpu (scene pass) — só-wgpu; glow no-op ───────────────── + .member(func( + "meshUpload", + "__RTS_FN_NS_EGUI_MESH_UPLOAD", + Sig::new(vec![U64, U64, I64, U64, I64], U64), + "meshUpload(h: number, vertsPtr: number, vertexCount: number, indicesPtr: number, indexCount: number): number", + "Uploads a mesh to GPU (verts = interleaved pos+normal, 6 floats/vertex at vertsPtr; indices = u32 at indicesPtr). Returns a mesh id. GPU 3D scene pass, drawn before the egui UI.", + scene_api::__RTS_FN_NS_EGUI_MESH_UPLOAD as *const u8, + )) + .member(func( + "meshFree", + "__RTS_FN_NS_EGUI_MESH_FREE", + Sig::new(vec![U64, U64], AbiType::Void), + "meshFree(h: number, meshId: number): void", + "Frees a mesh uploaded with meshUpload.", + scene_api::__RTS_FN_NS_EGUI_MESH_FREE as *const u8, + )) + .member(func( + "setCamera", + "__RTS_FN_NS_EGUI_SET_CAMERA", + Sig::new(vec![U64, F64, F64, F64, F64, F64, F64, F64], AbiType::Void), + "setCamera(h: number, camX: number, camY: number, camZ: number, yaw: number, pitch: number, fovY: number, aspect: number): void", + "Sets the 3D camera for this frame (fly cam: position + yaw/pitch + vertical FOV + aspect). Builds the view-projection.", + scene_api::__RTS_FN_NS_EGUI_SET_CAMERA as *const u8, + )) + .member(func( + "setCameraLookAt", + "__RTS_FN_NS_EGUI_SET_CAMERA_LOOKAT", + Sig::new(vec![U64, F64, F64, F64, F64, F64, F64, F64, F64, F64, F64], AbiType::Void), + "setCameraLookAt(h: number, eyeX: number, eyeY: number, eyeZ: number, targetX: number, targetY: number, targetZ: number, fovY: number, aspect: number, near: number, far: number): void", + "Sets the 3D camera as a NaN-safe look-at (eye position looking at a target, up = +Y) with explicit near/far. More convenient than yaw/pitch for framing an object; degrades gracefully when looking straight up/down instead of gimbal-locking.", + scene_api::__RTS_FN_NS_EGUI_SET_CAMERA_LOOKAT as *const u8, + )) + .member(func( + "setClearColor", + "__RTS_FN_NS_EGUI_SET_CLEAR_COLOR", + Sig::new(vec![U64, F64, F64, F64], AbiType::Void), + "setClearColor(h: number, r: number, g: number, b: number): void", + "Sets a FLAT background color (r,g,b in 0..1) for the 3D scene pass, disabling the procedural skybox. Ideal for an editor viewport that wants a neutral background instead of the starfield.", + scene_api::__RTS_FN_NS_EGUI_SET_CLEAR_COLOR as *const u8, + )) + .member(func( + "setSkybox", + "__RTS_FN_NS_EGUI_SET_SKYBOX", + Sig::new(vec![U64, I64], AbiType::Void), + "setSkybox(h: number, on: number): void", + "Re-enables the procedural skybox (on!=0), undoing a previous setClearColor.", + scene_api::__RTS_FN_NS_EGUI_SET_SKYBOX as *const u8, + )) + .member(func( + "setLight", + "__RTS_FN_NS_EGUI_SET_LIGHT", + Sig::new(vec![U64, F64, F64, F64, F64], AbiType::Void), + "setLight(h: number, dirX: number, dirY: number, dirZ: number, ambient: number): void", + "Sets the directional light (direction + ambient 0..1) for the 3D scene pass.", + scene_api::__RTS_FN_NS_EGUI_SET_LIGHT as *const u8, + )) + .member(func( + "winWidth", + "__RTS_FN_NS_EGUI_WIN_WIDTH", + Sig::new(vec![U64], F64), + "winWidth(h: number): number", + "Current LOGICAL width (points) of the window — follows resize.", + scene_api::__RTS_FN_NS_EGUI_WIN_WIDTH as *const u8, + )) + .member(func( + "winHeight", + "__RTS_FN_NS_EGUI_WIN_HEIGHT", + Sig::new(vec![U64], F64), + "winHeight(h: number): number", + "Current LOGICAL height (points) of the window — follows resize.", + scene_api::__RTS_FN_NS_EGUI_WIN_HEIGHT as *const u8, + )) + .member(func( + "setShadow", + "__RTS_FN_NS_EGUI_SET_SHADOW", + Sig::new(vec![U64, F64, F64, F64, F64, F64, F64, F64], AbiType::Void), + "setShadow(h: number, dirX: number, dirY: number, dirZ: number, cX: number, cY: number, cZ: number, radius: number): void", + "Configures the directional shadow map: light travel direction (dirX/Y/Z), the center and radius of the orthographic box the shadows cover. radius<=0 disables shadows.", + scene_api::__RTS_FN_NS_EGUI_SET_SHADOW as *const u8, + )) + .member(func( + "drawMesh", + "__RTS_FN_NS_EGUI_DRAW_MESH", + Sig::new(vec![U64, U64, F64, F64, F64, F64, F64, F64, F64, F64, I64, I64, I64], AbiType::Void), + "drawMesh(h: number, meshId: number, px: number, py: number, pz: number, rx: number, ry: number, sx: number, sy: number, sz: number, color: number, emissive: number, tex: number): void", + "Queues a 3D draw of meshId with a transform (pos/rot euler/scale), color 0xAARRGGBB, emissive flag (0/1), and procedural texture (0=none, 1=world-space checker). Rendered in the scene pass at endFrame. Light is a POINT light at setLight's position; shadows via setShadow.", + scene_api::__RTS_FN_NS_EGUI_DRAW_MESH as *const u8, + )) .done(); - // Namespace irmão `gpu3d`: scene pass 3D sob o overlay do egui (fase P7+ do - // design doc; spec docs/specs/gpu3d-scene-pass.md). Mesma doutrina Registry. - scene3d::register(e); } diff --git a/crates/rts-egui/src/scene3d/math3d.rs b/crates/rts-egui/src/scene3d/math3d.rs deleted file mode 100644 index 08ffea90..00000000 --- a/crates/rts-egui/src/scene3d/math3d.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Mat4 mínima para o scene pass 3D — perspectiva, look-at e composição -//! T·Ry·Rx·S. Column-major no layout de memória (o que o WGSL `mat4x4` -//! espera ao ler o uniform), mas as operações abaixo pensam em colunas como -//! vetores-base, convenção OpenGL/wgpu (clip-space Z em [0,1] — ver `perspective`). -//! -//! Feito à mão de propósito: são ~6 funções; puxar `glam` para isto adicionaria -//! uma dependência ao crate por nada. - -/// Matriz 4×4 column-major: `m[c]` é a coluna `c` (4 floats). -#[derive(Clone, Copy)] -pub struct Mat4(pub [[f32; 4]; 4]); - -impl Mat4 { - /// `self * rhs` (aplica `rhs` primeiro, depois `self`). - pub fn mul(&self, rhs: &Mat4) -> Mat4 { - let a = &self.0; - let b = &rhs.0; - let mut out = [[0.0f32; 4]; 4]; - for c in 0..4 { - for r in 0..4 { - out[c][r] = a[0][r] * b[c][0] - + a[1][r] * b[c][1] - + a[2][r] * b[c][2] - + a[3][r] * b[c][3]; - } - } - Mat4(out) - } - - /// Bytes do uniform (64 bytes, column-major — layout direto do `mat4x4`). - pub fn to_bytes(&self) -> [u8; 64] { - let mut out = [0u8; 64]; - for (i, col) in self.0.iter().enumerate() { - for (j, v) in col.iter().enumerate() { - out[i * 16 + j * 4..i * 16 + j * 4 + 4].copy_from_slice(&v.to_le_bytes()); - } - } - out - } -} - -/// Perspectiva com clip-space Z ∈ [0,1] (convenção wgpu/DX — NÃO a [-1,1] do GL). -/// `fovy` em radianos; `aspect` = largura/altura. -pub fn perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> Mat4 { - let f = 1.0 / (fovy / 2.0).tan(); - let r = far / (near - far); - Mat4([ - [f / aspect, 0.0, 0.0, 0.0], - [0.0, f, 0.0, 0.0], - [0.0, 0.0, r, -1.0], - [0.0, 0.0, r * near, 0.0], - ]) -} - -/// Câmera look-at (right-handed, up de referência +Y). Se `eye`≈`target` ou a -/// direção é paralela ao up, degrada com um up alternativo em vez de NaN. -pub fn look_at(eye: [f32; 3], target: [f32; 3]) -> Mat4 { - let fwd = norm(sub(target, eye)); - // up de referência: +Y; se a câmera olha quase reto pra cima/baixo, usa +Z. - let up_ref = if fwd[1].abs() > 0.999 { [0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0] }; - let right = norm(cross(fwd, up_ref)); - let up = cross(right, fwd); - Mat4([ - [right[0], up[0], -fwd[0], 0.0], - [right[1], up[1], -fwd[1], 0.0], - [right[2], up[2], -fwd[2], 0.0], - [-dot(right, eye), -dot(up, eye), dot(fwd, eye), 1.0], - ]) -} - -/// Modelo = Translate(x,y,z) · RotY(yaw) · RotX(pitch) · Scale(s). -pub fn model_trs(x: f32, y: f32, z: f32, yaw: f32, pitch: f32, s: f32) -> Mat4 { - let (sy, cy) = yaw.sin_cos(); - let (sp, cp) = pitch.sin_cos(); - // RotY·RotX·S composta direto (colunas = eixos rotacionados × escala). - Mat4([ - [cy * s, 0.0, -sy * s, 0.0], - [sy * sp * s, cp * s, cy * sp * s, 0.0], - [sy * cp * s, -sp * s, cy * cp * s, 0.0], - [x, y, z, 1.0], - ]) -} - -fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [a[0] - b[0], a[1] - b[1], a[2] - b[2]] -} - -fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { - a[0] * b[0] + a[1] * b[1] + a[2] * b[2] -} - -fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { - [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], - ] -} - -fn norm(v: [f32; 3]) -> [f32; 3] { - let len = dot(v, v).sqrt(); - if len < 1e-9 { - return [0.0, 0.0, -1.0]; - } - [v[0] / len, v[1] / len, v[2] / len] -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Um ponto na frente da câmera projeta dentro do clip volume com w>0 e - /// z∈[0,1] (convenção wgpu) — pega erro de sinal/transposição na pipeline. - #[test] - fn point_in_front_projects_inside_clip() { - let view = look_at([0.0, 0.0, 5.0], [0.0, 0.0, 0.0]); - let proj = perspective(60f32.to_radians(), 16.0 / 9.0, 0.1, 100.0); - let vp = proj.mul(&view); - // ponto na origem (5 unidades à frente da câmera) - let m = &vp.0; - let p = [0.0f32, 0.0, 0.0, 1.0]; - let mut clip = [0.0f32; 4]; - for r in 0..4 { - clip[r] = m[0][r] * p[0] + m[1][r] * p[1] + m[2][r] * p[2] + m[3][r] * p[3]; - } - assert!(clip[3] > 0.0, "w deve ser positivo à frente da câmera (w={})", clip[3]); - let ndc_z = clip[2] / clip[3]; - assert!( - (0.0..=1.0).contains(&ndc_z), - "z NDC deve cair em [0,1] (wgpu), veio {ndc_z}" - ); - } - - /// model_trs sem rotação/escala é uma translação pura. - #[test] - fn model_trs_translation_only() { - let m = model_trs(1.0, 2.0, 3.0, 0.0, 0.0, 1.0); - assert_eq!(m.0[3][0], 1.0); - assert_eq!(m.0[3][1], 2.0); - assert_eq!(m.0[3][2], 3.0); - assert_eq!(m.0[0][0], 1.0); - assert_eq!(m.0[1][1], 1.0); - assert_eq!(m.0[2][2], 1.0); - } -} diff --git a/crates/rts-egui/src/scene3d/mod.rs b/crates/rts-egui/src/scene3d/mod.rs deleted file mode 100644 index eea4daab..00000000 --- a/crates/rts-egui/src/scene3d/mod.rs +++ /dev/null @@ -1,390 +0,0 @@ -//! `gpu3d` — scene pass 3D real (malhas, câmera perspectiva, depth) renderizado -//! ANTES do pass do egui no mesmo encoder; o egui/DOM compõem por cima como -//! overlay. Primeira fatia da fase P7+ do design doc do egui. -//! Spec: `docs/specs/gpu3d-scene-pass.md`. -//! -//! Contrato imediato casando com o loop dirigido pelo TS: `mesh`/`camera` -//! persistem entre frames; `draw` enfileira instâncias do frame corrente e o -//! `endFrame` as consome. Vértices cruzam a ABI como handle de `buffer` -//! (f64 intercalado x,y,z,r,g,b — convertido a f32 no upload), nunca ponteiro. -//! -//! Só backend wgpu: no glow as chamadas são aceitas e ignoradas (mesma política -//! do `snapshot`). - -mod math3d; -mod pipeline; - -use std::collections::HashMap; - -use rts_engine::{AbiType, Engine, FnPtr, Member, MemberFlags, MemberKind, Sig}; -use AbiType::{F64, I64, U64}; - -use crate::ctx; -use crate::frame::Backend; - -/// Uma malha residente na GPU (buffers de vértice e opcionalmente índice). -pub struct Mesh { - pub vbuf: wgpu::Buffer, - pub ibuf: Option, - pub vertex_count: u32, - pub index_count: u32, -} - -/// Uma instância enfileirada por `gpu3d.draw` neste frame (ângulos em rad). -pub struct DrawCmd { - pub mesh_id: i64, - pub x: f32, - pub y: f32, - pub z: f32, - pub yaw: f32, - pub pitch: f32, - pub scale: f32, -} - -/// Estado 3D de UMA janela (vive em `RenderState.scene`, criado no 1º `mesh`). -pub struct SceneState { - pub meshes: HashMap, - pub next_mesh_id: i64, - pub draws: Vec, - pub eye: [f32; 3], - pub target: [f32; 3], - pub fovy_rad: f32, - pub near: f32, - pub far: f32, - pub clear_color: [f64; 3], - /// Pipeline/uniforms/depth — criados junto com o estado (precisam do - /// device + formato da surface, ambos disponíveis no 1º `mesh`). - pub gpu: Option, - /// Avisa UMA vez quando a fila estoura `MAX_DRAWS` (draws extras são - /// descartados — nunca truncar silenciosamente, ver regra no-silent-caps). - pub warned_overflow: bool, -} - -impl SceneState { - fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> SceneState { - SceneState { - meshes: HashMap::new(), - next_mesh_id: 1, - draws: Vec::new(), - eye: [0.0, 0.0, 5.0], - target: [0.0, 0.0, 0.0], - fovy_rad: 60f32.to_radians(), - near: 0.1, - far: 1000.0, - clear_color: [0.05, 0.06, 0.08], - gpu: Some(pipeline::GpuRes::new(device, surface_format)), - warned_overflow: false, - } - } -} - -/// Grava o pass da cena (chamado por `present_wgpu` antes do pass do egui). -/// Retorna `true` se a cena foi desenhada — o pass do egui então usa -/// `LoadOp::Load` em vez de `Clear` para compor por cima. -pub(crate) fn record_if_active( - scene: &mut Option, - device: &wgpu::Device, - queue: &wgpu::Queue, - encoder: &mut wgpu::CommandEncoder, - view: &wgpu::TextureView, - width: u32, - height: u32, -) -> bool { - let Some(s) = scene.as_mut() else { return false }; - if s.draws.is_empty() { - return false; - } - pipeline::record_scene_pass(s, device, queue, encoder, view, width, height); - true -} - -/// Roda `f` sobre o `SceneState` da janela, se existir (backend wgpu + cena já -/// criada pelo 1º `mesh`). No-op no glow ou sem cena — a política "aceita e -/// ignora" das chamadas gpu3d fora do wgpu. -fn with_scene(win: u64, f: impl FnOnce(&mut SceneState)) { - ctx::with_ctx(win, |c| match &mut c.backend { - Backend::Wgpu(r) => { - if let Some(s) = r.scene.as_mut() { - f(s); - } - } - #[cfg(feature = "glow-backend")] - Backend::Glow(_) => {} - }); -} - -/// Lê `count` f64 little-endian de um handle de `buffer` (Entry::Buffer). -/// `None` se o handle não é Buffer ou é curto demais. -fn read_f64s(handle: u64, count: usize) -> Option> { - rts_engine::heap::handles::with_entry(handle, |e| match e { - Some(rts_engine::heap::handles::Entry::Buffer(b)) => { - let need = count * 8; - (b.len() >= need).then(|| { - b[..need] - .chunks_exact(8) - .map(|c| f64::from_le_bytes(c.try_into().unwrap())) - .collect() - }) - } - _ => None, - }) -} - -/// Lê `count` i32 little-endian de um handle de `buffer` (índices). -fn read_i32s(handle: u64, count: usize) -> Option> { - rts_engine::heap::handles::with_entry(handle, |e| match e { - Some(rts_engine::heap::handles::Entry::Buffer(b)) => { - let need = count * 4; - (b.len() >= need).then(|| { - b[..need] - .chunks_exact(4) - .map(|c| i32::from_le_bytes(c.try_into().unwrap()) as u32) - .collect() - }) - } - _ => None, - }) -} - -/// Cria um `wgpu::Buffer` preenchido (mapped-at-creation; tamanho alinhado a 4). -fn gpu_buffer(device: &wgpu::Device, label: &str, bytes: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { - let size = (bytes.len() as u64 + 3) & !3; - let buf = device.create_buffer(&wgpu::BufferDescriptor { - label: Some(label), - size, - usage, - mapped_at_creation: true, - }); - let mut view = buf.get_mapped_range_mut(..); - view.slice(..bytes.len()).copy_from_slice(bytes); - drop(view); - buf.unmap(); - buf -} - -/// Upload comum de malha: vértices (e índices opcionais) já lidos dos buffers. -/// Retorna o meshId novo (>0). -fn upload_mesh( - r: &mut crate::frame::RenderState, - verts: Vec, - vertex_count: u32, - indices: Option>, -) -> i64 { - // f64 intercalado (x,y,z,r,g,b) → f32 empacotado no layout do shader. - let mut vbytes = Vec::with_capacity(verts.len() * 4); - for v in &verts { - vbytes.extend_from_slice(&(*v as f32).to_le_bytes()); - } - if r.scene.is_none() { - r.scene = Some(SceneState::new(&r.device, r.config.format)); - } - let scene = r.scene.as_mut().unwrap(); - let vbuf = gpu_buffer(&r.device, "rts-gpu3d vbuf", &vbytes, wgpu::BufferUsages::VERTEX); - let (ibuf, index_count) = match indices { - Some(idx) => { - let mut ibytes = Vec::with_capacity(idx.len() * 4); - for i in &idx { - ibytes.extend_from_slice(&i.to_le_bytes()); - } - let count = idx.len() as u32; - ( - Some(gpu_buffer(&r.device, "rts-gpu3d ibuf", &ibytes, wgpu::BufferUsages::INDEX)), - count, - ) - } - None => (None, 0), - }; - let id = scene.next_mesh_id; - scene.next_mesh_id += 1; - scene.meshes.insert(id, Mesh { vbuf, ibuf, vertex_count, index_count }); - id -} - -#[unsafe(no_mangle)] -pub extern "C" fn __RTS_FN_NS_GPU3D_MESH(win: u64, buf: u64, vert_count: i64) -> i64 { - if vert_count <= 0 { - return 0; - } - let Some(verts) = read_f64s(buf, vert_count as usize * 6) else { return 0 }; - ctx::with_ctx(win, |c| match &mut c.backend { - Backend::Wgpu(r) => upload_mesh(r, verts, vert_count as u32, None), - #[cfg(feature = "glow-backend")] - Backend::Glow(_) => 0, - }) - .unwrap_or(0) -} - -#[unsafe(no_mangle)] -pub extern "C" fn __RTS_FN_NS_GPU3D_MESH_INDEXED( - win: u64, - buf: u64, - vert_count: i64, - idx: u64, - idx_count: i64, -) -> i64 { - if vert_count <= 0 || idx_count <= 0 { - return 0; - } - let Some(verts) = read_f64s(buf, vert_count as usize * 6) else { return 0 }; - let Some(indices) = read_i32s(idx, idx_count as usize) else { return 0 }; - if indices.iter().any(|i| *i >= vert_count as u32) { - return 0; // índice fora do range de vértices = malha inválida - } - ctx::with_ctx(win, |c| match &mut c.backend { - Backend::Wgpu(r) => upload_mesh(r, verts, vert_count as u32, Some(indices)), - #[cfg(feature = "glow-backend")] - Backend::Glow(_) => 0, - }) - .unwrap_or(0) -} - -#[unsafe(no_mangle)] -pub extern "C" fn __RTS_FN_NS_GPU3D_MESH_FREE(win: u64, mesh_id: i64) { - with_scene(win, |s| { - s.meshes.remove(&mesh_id); - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn __RTS_FN_NS_GPU3D_CAMERA(win: u64, ex: f64, ey: f64, ez: f64, tx: f64, ty: f64, tz: f64) { - with_scene(win, |s| { - s.eye = [ex as f32, ey as f32, ez as f32]; - s.target = [tx as f32, ty as f32, tz as f32]; - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn __RTS_FN_NS_GPU3D_PERSPECTIVE(win: u64, fov_y_deg: f64, near: f64, far: f64) { - with_scene(win, |s| { - if fov_y_deg > 0.0 && fov_y_deg < 180.0 { - s.fovy_rad = (fov_y_deg as f32).to_radians(); - } - if near > 0.0 && far > near { - s.near = near as f32; - s.far = far as f32; - } - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn __RTS_FN_NS_GPU3D_DRAW( - win: u64, - mesh_id: i64, - x: f64, - y: f64, - z: f64, - yaw_deg: f64, - pitch_deg: f64, - scale: f64, -) { - with_scene(win, |s| { - if s.draws.len() >= pipeline::MAX_DRAWS { - if !s.warned_overflow { - s.warned_overflow = true; - eprintln!( - "rts-gpu3d: mais de {} draws num frame — extras descartados", - pipeline::MAX_DRAWS - ); - } - return; - } - s.draws.push(DrawCmd { - mesh_id, - x: x as f32, - y: y as f32, - z: z as f32, - yaw: (yaw_deg as f32).to_radians(), - pitch: (pitch_deg as f32).to_radians(), - scale: scale as f32, - }); - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn __RTS_FN_NS_GPU3D_CLEAR_COLOR(win: u64, red: f64, green: f64, blue: f64) { - with_scene(win, |s| { - s.clear_color = [red.clamp(0.0, 1.0), green.clamp(0.0, 1.0), blue.clamp(0.0, 1.0)]; - }); -} - -/// Helper de declaração de membro (mesmo shape do `func` de `lib.rs`). -fn func(name: &str, symbol: &str, sig: Sig, ts: &str, doc: &str, fp: *const u8) -> Member { - Member { - name: name.to_string(), - kind: MemberKind::Function, - sig, - symbol: symbol.to_string(), - fn_ptr: FnPtr(fp), - flags: MemberFlags::NONE, - aliases: Vec::new(), - variadic: false, - ts_signature: ts.to_string(), - doc: doc.to_string(), - pure: false, - emit: None, - } -} - -/// Registra o namespace `gpu3d` (chamado por `rts_egui::register`, doutrina -/// Registry — o engine nunca nomeia `gpu3d`). -pub fn register(e: &mut Engine) { - e.ns("gpu3d") - .doc("Real 3D scene pass under the egui overlay: meshes, perspective camera, depth. Spec: docs/specs/gpu3d-scene-pass.md") - .member(func( - "mesh", - "__RTS_FN_NS_GPU3D_MESH", - Sig::new(vec![U64, U64, I64], I64), - "mesh(win: number, verts: number, vertCount: number): number", - "Uploads a triangle-list mesh. `verts` = buffer handle with vertCount*6 f64 (x,y,z,r,g,b interleaved, colors 0..1). Returns meshId (>0) or 0 on error.", - __RTS_FN_NS_GPU3D_MESH as *const u8, - )) - .member(func( - "meshIndexed", - "__RTS_FN_NS_GPU3D_MESH_INDEXED", - Sig::new(vec![U64, U64, I64, U64, I64], I64), - "meshIndexed(win: number, verts: number, vertCount: number, idx: number, idxCount: number): number", - "Uploads an indexed mesh: `idx` = buffer handle with idxCount i32 indices (write_i32). Returns meshId (>0) or 0 on error.", - __RTS_FN_NS_GPU3D_MESH_INDEXED as *const u8, - )) - .member(func( - "meshFree", - "__RTS_FN_NS_GPU3D_MESH_FREE", - Sig::new(vec![U64, I64], AbiType::Void), - "meshFree(win: number, meshId: number): void", - "Frees the GPU buffers of a mesh.", - __RTS_FN_NS_GPU3D_MESH_FREE as *const u8, - )) - .member(func( - "camera", - "__RTS_FN_NS_GPU3D_CAMERA", - Sig::new(vec![U64, F64, F64, F64, F64, F64, F64], AbiType::Void), - "camera(win: number, ex: number, ey: number, ez: number, tx: number, ty: number, tz: number): void", - "Look-at camera: eye position + target point, up = +Y. Persists across frames.", - __RTS_FN_NS_GPU3D_CAMERA as *const u8, - )) - .member(func( - "perspective", - "__RTS_FN_NS_GPU3D_PERSPECTIVE", - Sig::new(vec![U64, F64, F64, F64], AbiType::Void), - "perspective(win: number, fovYDeg: number, near: number, far: number): void", - "Projection params (defaults 60deg, 0.1, 1000). Aspect ratio tracks the window size automatically.", - __RTS_FN_NS_GPU3D_PERSPECTIVE as *const u8, - )) - .member(func( - "draw", - "__RTS_FN_NS_GPU3D_DRAW", - Sig::new(vec![U64, I64, F64, F64, F64, F64, F64, F64], AbiType::Void), - "draw(win: number, meshId: number, x: number, y: number, z: number, yawDeg: number, pitchDeg: number, scale: number): void", - "Queues one instance of the mesh this frame (model = T*Ry(yaw)*Rx(pitch)*S). endFrame renders the scene BEFORE the egui pass and clears the queue.", - __RTS_FN_NS_GPU3D_DRAW as *const u8, - )) - .member(func( - "clearColor", - "__RTS_FN_NS_GPU3D_CLEAR_COLOR", - Sig::new(vec![U64, F64, F64, F64], AbiType::Void), - "clearColor(win: number, r: number, g: number, b: number): void", - "Scene background color (components 0..1). Only used on frames where draw() was called.", - __RTS_FN_NS_GPU3D_CLEAR_COLOR as *const u8, - )) - .done(); -} diff --git a/crates/rts-egui/src/scene3d/pipeline.rs b/crates/rts-egui/src/scene3d/pipeline.rs deleted file mode 100644 index bdeb5773..00000000 --- a/crates/rts-egui/src/scene3d/pipeline.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! Pipeline wgpu do scene pass 3D: shader WGSL (pos+cor, MVP por draw via -//! dynamic uniform offset), depth buffer `Depth32Float` e a gravação do render -//! pass da cena — que roda ANTES do pass do egui, no MESMO encoder (o egui vira -//! overlay; ver `docs/specs/gpu3d-scene-pass.md`). - -use super::{DrawCmd, SceneState}; -use super::math3d; - -/// Stride de um vértice: pos f32×3 + cor f32×3 = 24 bytes. -pub const VERTEX_STRIDE: u64 = 24; - -/// Alinhamento mínimo de dynamic offset em uniform buffers exigido pela spec -/// WebGPU (256 em todo hardware relevante). Cada draw ocupa um slot deste -/// tamanho no uniform buffer (mat4 = 64 bytes + padding). -pub const UNIFORM_SLOT: u64 = 256; - -/// Máximo de draws por frame — dimensiona o uniform buffer (4096×256 = 1 MiB). -pub const MAX_DRAWS: usize = 4096; - -const SHADER: &str = r#" -struct Uniforms { mvp: mat4x4 }; -@group(0) @binding(0) var u: Uniforms; - -struct VsOut { - @builtin(position) pos: vec4, - @location(0) color: vec3, -}; - -@vertex -fn vs_main(@location(0) pos: vec3, @location(1) color: vec3) -> VsOut { - var out: VsOut; - out.pos = u.mvp * vec4(pos, 1.0); - out.color = color; - return out; -} - -@fragment -fn fs_main(in: VsOut) -> @location(0) vec4 { - return vec4(in.color, 1.0); -} -"#; - -/// Recursos de GPU do pass 3D, criados UMA vez por janela (lazy, no 1º mesh). -pub struct GpuRes { - pub pipeline: wgpu::RenderPipeline, - pub bind_group: wgpu::BindGroup, - pub uniforms: wgpu::Buffer, - /// Depth texture + view, recriadas quando o tamanho da surface muda. - pub depth_view: wgpu::TextureView, - pub depth_size: (u32, u32), -} - -impl GpuRes { - pub fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> GpuRes { - let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("rts-gpu3d shader"), - source: wgpu::ShaderSource::Wgsl(SHADER.into()), - }); - let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("rts-gpu3d bgl"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: true, - min_binding_size: std::num::NonZeroU64::new(64), - }, - count: None, - }], - }); - let uniforms = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("rts-gpu3d uniforms"), - size: UNIFORM_SLOT * MAX_DRAWS as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("rts-gpu3d bind group"), - layout: &bgl, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { - buffer: &uniforms, - offset: 0, - size: std::num::NonZeroU64::new(64), - }), - }], - }); - let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("rts-gpu3d layout"), - bind_group_layouts: &[Some(&bgl)], - immediate_size: 0, - }); - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("rts-gpu3d pipeline"), - layout: Some(&layout), - vertex: wgpu::VertexState { - module: &module, - entry_point: Some("vs_main"), - buffers: &[wgpu::VertexBufferLayout { - array_stride: VERTEX_STRIDE, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3], - }], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &module, - 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, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - // Sem cull no MVP: o TS ainda não tem por que garantir winding - // consistente nos meshes; descartar triângulos "de costas" - // agora só produziria buracos surpresa. Cull entra com a fatia - // de iluminação (normais tornam o winding significativo). - cull_mode: None, - unclipped_depth: false, - polygon_mode: wgpu::PolygonMode::Fill, - conservative: false, - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, - depth_write_enabled: Some(true), - depth_compare: Some(wgpu::CompareFunction::Less), - stencil: wgpu::StencilState::default(), - bias: wgpu::DepthBiasState::default(), - }), - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - // depth criada no 1º ensure_depth (precisa do tamanho real da surface). - let depth_view = create_depth(device, 1, 1); - GpuRes { - pipeline, - bind_group, - uniforms, - depth_view, - depth_size: (1, 1), - } - } - - /// Garante que a depth texture casa com o tamanho atual da surface. - pub fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) { - if self.depth_size != (w, h) && w > 0 && h > 0 { - self.depth_view = create_depth(device, w, h); - self.depth_size = (w, h); - } - } -} - -fn create_depth(device: &wgpu::Device, w: u32, h: u32) -> wgpu::TextureView { - let tex = device.create_texture(&wgpu::TextureDescriptor { - label: Some("rts-gpu3d 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: wgpu::TextureFormat::Depth32Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - view_formats: &[], - }); - tex.create_view(&wgpu::TextureViewDescriptor::default()) -} - -/// Grava o render pass da cena no `encoder`, sobre `view` (a MESMA texture da -/// surface que o pass do egui usa em seguida com `LoadOp::Load`). Limpa cor + -/// depth, desenha cada `DrawCmd` com seu MVP (uniform em dynamic offset) e -/// esvazia a fila. Chamado por `present_wgpu` quando `scene.draws` não é vazio. -pub fn record_scene_pass( - scene: &mut SceneState, - device: &wgpu::Device, - queue: &wgpu::Queue, - encoder: &mut wgpu::CommandEncoder, - view: &wgpu::TextureView, - width: u32, - height: u32, -) { - let aspect = width.max(1) as f32 / height.max(1) as f32; - let proj = math3d::perspective(scene.fovy_rad, aspect, scene.near, scene.far); - let view_m = math3d::look_at(scene.eye, scene.target); - let vp = proj.mul(&view_m); - - // MVP por draw nos slots do uniform buffer (um write só, contíguo). - let draws: Vec = std::mem::take(&mut scene.draws); - let mut ubytes = vec![0u8; draws.len() * UNIFORM_SLOT as usize]; - for (i, d) in draws.iter().enumerate() { - let model = math3d::model_trs(d.x, d.y, d.z, d.yaw, d.pitch, d.scale); - let mvp = vp.mul(&model); - ubytes[i * UNIFORM_SLOT as usize..i * UNIFORM_SLOT as usize + 64] - .copy_from_slice(&mvp.to_bytes()); - } - let res = scene.gpu.as_mut().expect("record_scene_pass sem GpuRes"); - queue.write_buffer(&res.uniforms, 0, &ubytes); - res.ensure_depth(device, width, height); - - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("rts-gpu3d scene pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: scene.clear_color[0], - g: scene.clear_color[1], - b: scene.clear_color[2], - a: 1.0, - }), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &res.depth_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Discard, - }), - stencil_ops: None, - }), - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - - pass.set_pipeline(&res.pipeline); - for (i, d) in draws.iter().enumerate() { - let Some(mesh) = scene.meshes.get(&d.mesh_id) else { continue }; - pass.set_bind_group(0, &res.bind_group, &[(i as u32) * UNIFORM_SLOT as u32]); - pass.set_vertex_buffer(0, mesh.vbuf.slice(..)); - match &mesh.ibuf { - Some(ib) => { - pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..mesh.index_count, 0, 0..1); - } - None => pass.draw(0..mesh.vertex_count, 0..1), - } - } -} diff --git a/crates/rts-egui/src/scene_api.rs b/crates/rts-egui/src/scene_api.rs new file mode 100644 index 00000000..582cf48e --- /dev/null +++ b/crates/rts-egui/src/scene_api.rs @@ -0,0 +1,198 @@ +//! Superfície `extern "C"` do pipeline 3D wgpu, exposta no namespace `egui` +//! (tied ao handle da janela). NÃO mexe na trait neutra `rts-render::Renderer`. +//! +//! A TS passa PONTEIROS crus (via `buffer.ptr`) pros dados de vértice/índice, e +//! os campos de câmera/transform como f64 — a matriz view·proj e a model são +//! construídas em Rust (`scene3d::view_proj`/`model_matrix`), casando a convenção +//! do rasterizador software. Só o braço `Backend::Wgpu` age; no glow é no-op. + +use crate::ctx::with_ctx; +use crate::frame::scene3d::{model_matrix, view_proj, view_proj_lookat, Scene3D}; +use crate::frame::Backend; + +/// Garante o pipeline 3D criado na 1ª chamada e roda `f(scene, device)`. +fn with_scene(win: u64, f: impl FnOnce(&mut Scene3D, &wgpu::Device) -> R, default: R) -> R { + with_ctx(win, |c| { + if let Backend::Wgpu(r) = &mut c.backend { + if r.scene.is_none() { + r.scene = Some(Scene3D::new(&r.device, r.config.format)); + } + let dev = &r.device; + let s = r.scene.as_mut().unwrap(); + f(s, dev) + } else { + default + } + }) + .unwrap_or(default) +} + +/// Sobe uma mesh (verts interleaved pos+normal = 6 f32/vértice; índices u32) pra +/// VRAM. Retorna o id da mesh (0 se a janela não é wgpu). +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_MESH_UPLOAD( + win: u64, + vptr: u64, + vcount: i64, + iptr: u64, + icount: i64, +) -> u64 { + if vptr == 0 || iptr == 0 || vcount <= 0 || icount <= 0 { + return 0; + } + let verts = unsafe { std::slice::from_raw_parts(vptr as *const f32, (vcount * 6) as usize) }; + let inds = unsafe { std::slice::from_raw_parts(iptr as *const u32, icount as usize) }; + with_scene(win, |s, dev| s.upload_mesh(dev, verts, inds), 0) +} + +/// Libera uma mesh da VRAM. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_MESH_FREE(win: u64, mesh: u64) { + with_scene(win, |s, _d| s.free_mesh(mesh), ()); +} + +/// Define a câmera do frame (fly: yaw/pitch/fov/aspect). Constrói view·proj. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_SET_CAMERA( + win: u64, + camx: f64, + camy: f64, + camz: f64, + yaw: f64, + pitch: f64, + fov_y: f64, + aspect: f64, +) { + let cd = view_proj( + camx as f32, camy as f32, camz as f32, yaw as f32, pitch as f32, fov_y as f32, aspect as f32, + ); + with_scene(win, |s, _d| s.set_camera(cd), ()); +} + +/// Câmera LOOK-AT (olho→alvo) com `near`/`far` explícitos — NaN-safe (não trava +/// no gimbal ao olhar reto pra cima/baixo). Mais conveniente que yaw/pitch pro +/// "frame selected" do editor. Enxertado do gpu3d (origin/main), adaptado à base LH. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_SET_CAMERA_LOOKAT( + win: u64, + ex: f64, + ey: f64, + ez: f64, + tx: f64, + ty: f64, + tz: f64, + fov_y: f64, + aspect: f64, + near: f64, + far: f64, +) { + let cd = view_proj_lookat( + [ex as f32, ey as f32, ez as f32], + [tx as f32, ty as f32, tz as f32], + fov_y as f32, + aspect as f32, + near as f32, + far as f32, + ); + with_scene(win, |s, _d| s.set_camera(cd), ()); +} + +/// Fundo CHAPADO do scene pass (r,g,b em 0..1) — desliga o skybox procedural. +/// Ideal pro viewport do editor, que quer um fundo neutro em vez do starfield. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_SET_CLEAR_COLOR(win: u64, r: f64, g: f64, b: f64) { + with_scene(win, |s, _d| s.set_clear_color([r as f32, g as f32, b as f32, 1.0]), ()); +} + +/// Religa o skybox procedural (`on!=0`) desfazendo um `setClearColor`. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_SET_SKYBOX(win: u64, on: i64) { + with_scene(win, |s, _d| s.set_skybox(on != 0), ()); +} + +/// Define a luz direcional (dir + ambiente). +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_SET_LIGHT(win: u64, dx: f64, dy: f64, dz: f64, ambient: f64) { + with_scene( + win, + |s, _d| s.set_light([dx as f32, dy as f32, dz as f32], ambient as f32), + (), + ); +} + +/// Largura LÓGICA (points) atual da janela — segue o resize. 0 se não existe. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_WIN_WIDTH(win: u64) -> f64 { + crate::ctx::with_ctx(win, |c| { + let s = c.window.inner_size(); + let sf = c.window.scale_factor(); + ((s.width as f64) / sf).round() + }) + .unwrap_or(0.0) +} + +/// Altura LÓGICA (points) atual da janela — segue o resize. 0 se não existe. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_WIN_HEIGHT(win: u64) -> f64 { + crate::ctx::with_ctx(win, |c| { + let s = c.window.inner_size(); + let sf = c.window.scale_factor(); + ((s.height as f64) / sf).round() + }) + .unwrap_or(0.0) +} + +/// Configura o shadow map: direção da luz (dx,dy,dz = para onde a luz viaja) + +/// centro (cx,cy,cz) e raio da caixa que a sombra cobre. radius<=0 desliga. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_SET_SHADOW( + win: u64, + dx: f64, + dy: f64, + dz: f64, + cx: f64, + cy: f64, + cz: f64, + radius: f64, +) { + with_scene( + win, + |s, _d| s.set_shadow([dx as f32, dy as f32, dz as f32], [cx as f32, cy as f32, cz as f32], radius as f32), + (), + ); +} + +/// Enfileira 1 draw da mesh `mesh` com transform (pos/rot/escala) + cor +/// (0xAARRGGBB) + emissivo (0/1) + textura procedural (0=nenhuma, 1=xadrez). +/// O draw acontece no scene pass, no `endFrame`. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_EGUI_DRAW_MESH( + win: u64, + mesh: u64, + px: f64, + py: f64, + pz: f64, + rx: f64, + ry: f64, + sx: f64, + sy: f64, + sz: f64, + color: i64, + emissive: i64, + tex: i64, +) { + let m = model_matrix( + px as f32, py as f32, pz as f32, rx as f32, ry as f32, sx as f32, sy as f32, sz as f32, + ); + let c = color as u32; + let a = (c >> 24) & 0xFF; + let col = [ + ((c >> 16) & 0xFF) as f32 / 255.0, + ((c >> 8) & 0xFF) as f32 / 255.0, + (c & 0xFF) as f32 / 255.0, + if a == 0 { 1.0 } else { a as f32 / 255.0 }, // sem byte de alpha = opaco + ]; + let em = if emissive != 0 { 1.0 } else { 0.0 }; + let tx = if tex != 0 { 1.0 } else { 0.0 }; + with_scene(win, |s, _d| s.queue_draw(mesh, m, col, em, tx), ()); +} diff --git a/crates/rts-std/src/net/mod.rs b/crates/rts-std/src/net/mod.rs index 2d05589e..60a97f9d 100644 --- a/crates/rts-std/src/net/mod.rs +++ b/crates/rts-std/src/net/mod.rs @@ -58,7 +58,8 @@ pub extern "C" fn __RTS_FN_NS_NET_TCP_LISTEN(addr_ptr: *const u8, addr_len: i64) } } -/// Accept a connection on `listener`. Stream handle, 0 on error. +/// Accept a connection on `listener`. Stream handle, 0 on error/would-block. +/// Com set_nonblocking(true), 0 significa "nenhum cliente ainda" (faça poll). #[unsafe(no_mangle)] pub extern "C" fn __RTS_FN_NS_NET_TCP_ACCEPT(listener: U64) -> Handle { let Some(l) = clone_listener(listener) else { @@ -70,6 +71,23 @@ pub extern "C" fn __RTS_FN_NS_NET_TCP_ACCEPT(listener: U64) -> Handle { } } +/// Marca um listener/stream como não-bloqueante (on!=0) ou bloqueante (0). Com +/// non-blocking, `tcp_accept` devolve 0 quando não há cliente e `tcp_recv` +/// devolve -2 (WouldBlock) — o loop pode renderizar sem travar no recv. 0 = ok, -1 = erro. +#[unsafe(no_mangle)] +pub extern "C" fn __RTS_FN_NS_NET_TCP_SET_NONBLOCKING(handle: U64, on: I64) -> I64 { + let nb = on != 0; + let r = with_entry(handle, |entry| match entry { + Some(Entry::TcpListener(l)) => Some(l.set_nonblocking(nb).is_ok()), + Some(Entry::TcpStream(s)) => Some(s.set_nonblocking(nb).is_ok()), + _ => None, + }); + match r { + Some(true) => 0, + _ => -1, + } +} + /// Connect a TCP stream to `addr`. Handle, 0 on error. #[unsafe(no_mangle)] pub extern "C" fn __RTS_FN_NS_NET_TCP_CONNECT(addr_ptr: *const u8, addr_len: i64) -> Handle { @@ -112,6 +130,8 @@ pub extern "C" fn __RTS_FN_NS_NET_TCP_RECV(stream: U64, buf_ptr: U64, len: I64) let dst = unsafe { std::slice::from_raw_parts_mut(buf_ptr as *mut u8, len as usize) }; match s.read(dst) { Ok(n) => n as i64, + // non-blocking sem dados prontos: -2 (distinto de 0=fechado e -1=erro) + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => -2, Err(_) => -1, } } @@ -294,6 +314,14 @@ pub fn register(e: &mut Engine) { "Accept a connection on `listener`. Stream handle, 0 on error.", __RTS_FN_NS_NET_TCP_ACCEPT as *const u8, )) + .member(func( + "tcp_set_nonblocking", + "__RTS_FN_NS_NET_TCP_SET_NONBLOCKING", + Sig::new(vec![AbiType::U64, AbiType::I64], AbiType::I64), + "tcp_set_nonblocking(handle: number, on: number): number", + "Marks a listener/stream non-blocking (on!=0). Then tcp_accept returns 0 when no client is pending and tcp_recv returns -2 (WouldBlock) instead of blocking. Returns 0 ok, -1 error.", + __RTS_FN_NS_NET_TCP_SET_NONBLOCKING as *const u8, + )) .member(func( "tcp_connect", "__RTS_FN_NS_NET_TCP_CONNECT", diff --git a/docs/specs/gpu3d-scene-pass.md b/docs/specs/gpu3d-scene-pass.md index 66a9788b..dd29b635 100644 --- a/docs/specs/gpu3d-scene-pass.md +++ b/docs/specs/gpu3d-scene-pass.md @@ -1,5 +1,17 @@ # gpu3d — 3D scene pass under the egui overlay +> **SUPERSEDED (2026-07-23).** The `gpu3d` namespace this doc describes was a +> parallel MVP of the 3D scene pass. It was reconciled with the richer scene pass +> developed on the `feat/rts-egui-3d-scene-editor` branch (shadow map/PCF, point +> light, specular, skybox, procedural texture, emissive, instancing), which won +> as the base. The good parts of `gpu3d` were **grafted into that surface** — the +> live API is the **`egui.*`** namespace (`egui.meshUpload/drawMesh/setCamera/ +> setCameraLookAt/setLight/setShadow/setClearColor/setSkybox`), not a separate +> `gpu3d` namespace. The concepts below (scene pass before the egui pass, shared +> encoder, depth buffer, look-at camera, flat clear color) remain accurate; only +> the namespace name and the per-vertex-color MVP limitation are stale. Impl lives +> in `crates/rts-egui/src/frame/scene3d.rs` + `scene_api.rs`. + **Status:** first slice (MVP) — colored-triangle meshes, depth-tested, camera, per-draw transform. **Owner decision 2026-07-21.**