From 4556376b4ae1dfa2e496e33368dde86dff1fb21c Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 13 Jul 2026 16:43:01 +0800 Subject: [PATCH] fix: make the black hole actually render (was stuck on grey clear color) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app showed only the camera clear color (grey srgb 43,44,47) — the fullscreen quad's fragment shader produced no output. Two issues; the second was the real blocker. 1. render/material.rs: skybox.wgsl declared binding 1 as texture_cube but the material's #[texture(1)] defaulted to D2. The mismatched bind-group layout vs. shader caused the pipeline to fail to specialize. Fix: dimension = "cube" on the texture attribute so the layout matches. 2. SHADER COMPOSITION (the actual blocker): the shader was split across naga_oil modules (ray_gen, geodesic, stars, disk, planets, grid, skybox) pulled in via #import singularity::... This compiled and validated with zero errors, but at runtime calling ANY cross-module-imported function made the fragment output nothing (only the clear color showed). Local functions were fine. Confirmed by bisecting a minimal repro with the user watching the screen: local fn -> renders; imported fn (even pure math) -> grey. Rather than chase the naga_oil 0.22 / Bevy 0.19 composition bug, inline every function into black_hole.wgsl and drop the #import singularity::* lines. The 7 standalone module files are removed. Rendering now works. Also fixes scene/planets.rs: upload_planets was allocating a fresh ShaderBuffer (new handle) every frame, which re-triggered the AsBindGroup RetryNextUpdate that the previous commit's startup pre-fill was meant to eliminate. Now mutates the existing buffer asset in place via set_data, keeping the handle stable. --- assets/shaders/black_hole.wgsl | 238 +++++++++++++++++++-- assets/shaders/disk.wgsl | 62 ------ assets/shaders/geodesic_schwarzschild.wgsl | 42 ---- assets/shaders/grid.wgsl | 51 ----- assets/shaders/planets.wgsl | 57 ----- assets/shaders/ray_gen.wgsl | 16 -- assets/shaders/skybox.wgsl | 9 - assets/shaders/stars.wgsl | 29 --- src/render/material.rs | 9 +- src/scene/planets.rs | 34 ++- 10 files changed, 249 insertions(+), 298 deletions(-) delete mode 100644 assets/shaders/disk.wgsl delete mode 100644 assets/shaders/geodesic_schwarzschild.wgsl delete mode 100644 assets/shaders/grid.wgsl delete mode 100644 assets/shaders/planets.wgsl delete mode 100644 assets/shaders/ray_gen.wgsl delete mode 100644 assets/shaders/skybox.wgsl delete mode 100644 assets/shaders/stars.wgsl diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index 637ceb3..4b5a112 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -1,14 +1,15 @@ +// All shader logic is inlined into this single file. +// +// HISTORY: the shader used to be split across several naga_oil modules +// (ray_gen, geodesic, stars, disk, planets, grid, skybox) that each +// `#define_import_path singularity::...` and were pulled into this file via +// `#import singularity::...`. That compiled without error, but calling ANY +// imported function at runtime produced no fragment output — the fullscreen +// quad silently drew nothing and only the camera clear color (grey) showed. +// (Local functions worked; only cross-module imports broke.) Rather than chase +// the naga_oil composition bug, every function is inlined here. The standalone +// module files still exist on disk but are no longer imported. #import bevy_sprite::mesh2d_vertex_output::VertexOutput -// Bevy/naga_oil imports: each module file uses #define_import_path, then we -// import individual symbols via `namespace::name` (or `namespace::{a, b}`). -// Whole-file imports without `::` do NOT reliably bring functions into scope. -#import singularity::ray_gen::ray_direction -#import singularity::geodesic::{deriv, classify_ray} -#import singularity::stars::{hash13, star_color} -#import singularity::disk::{rot_x, disk_hit, disk_color} -#import singularity::planets::{SphereData, planets, planet_hit} -#import singularity::grid::{flamm_depth, grid_hit} -#import singularity::skybox::skybox_color struct BlackHoleUniforms { eye: vec4, @@ -38,6 +39,207 @@ struct BlackHoleUniforms { @group(#{MATERIAL_BIND_GROUP}) @binding(0) var uniforms: BlackHoleUniforms; +// ---------- planets storage (binding 3) ---------- +struct SphereData { + center: vec4, // xyz = center (world space), w = radius + color: vec4, // xyz = color, w = emissive flag +}; +@group(#{MATERIAL_BIND_GROUP}) @binding(3) var planets: array; + +// ---------- optional cubemap skybox (bindings 1 & 2) ---------- +@group(#{MATERIAL_BIND_GROUP}) @binding(1) var skybox: texture_cube; +@group(#{MATERIAL_BIND_GROUP}) @binding(2) var skybox_sampler: sampler; + +// ====================== inlined helpers ====================== + +// Rotate a vector around the X axis by angle a. +fn rot_x(v: vec3, a: f32) -> vec3 { + let c = cos(a); + let s = sin(a); + return vec3(v.x, c * v.y - s * v.z, s * v.y + c * v.z); +} + +// --- ray_gen --- +// `fov` is packed into the `.w` of `up` (Rust lays out `up: Vec3` + `fov: f32` +// as one vec4 block). +fn ray_direction(uv: vec2) -> vec3 { + let tan_half_fov = tan(uniforms.up.w * 0.5); + let dir = + normalize(uniforms.forward.xyz) + + uniforms.right.xyz * (uv.x * tan_half_fov) + + uniforms.up.xyz * (uv.y * tan_half_fov); + return normalize(dir); +} + +// --- stars --- +fn hash13(p: vec3) -> f32 { + var q = vec3(dot(p, vec3(127.1, 311.7, 74.7)), + dot(p, vec3(269.5, 183.3, 246.1)), + dot(p, vec3(113.5, 271.9, 124.6))); + let h = fract(sin(q) * 43758.5453); + return h.x; +} + +fn star_color(dir: vec3, intensity: f32) -> vec3 { + let scale = 80.0; + let cell = floor(dir * scale); + let h = hash13(cell); + let threshold = 0.985; + if (h > threshold) { + let b = (h - threshold) / (1.0 - threshold); + let col = mix(vec3(0.6, 0.7, 1.0), vec3(1.0, 0.9, 0.7), b); + let f = abs(dir * scale - cell); + let d = max(f.x, max(f.y, f.z)); + let falloff = smoothstep(0.5, 0.0, d); + return col * b * falloff * 3.0 * intensity; + } + return vec3(0.0); +} + +// --- skybox --- +fn skybox_color(dir: vec3) -> vec3 { + return textureSample(skybox, skybox_sampler, dir).rgb; +} + +// --- geodesic --- +struct Deriv { + dpos: vec3, + ddir: vec3, +} + +fn deriv(pos: vec3, dir: vec3) -> Deriv { + let r = length(pos); + let rs = uniforms.rs; + let h = cross(pos, dir); + let h2 = dot(h, h); + let r5 = max(r * r * r * r * r, 1e-6); + let dpos = dir; + let accel = -1.5 * rs * h2 / r5 * pos; + return Deriv(dpos, accel); +} + +// --- disk --- +fn disk_hit(prev: vec3, cur: vec3) -> bool { + let y0 = prev.y; + let y1 = cur.y; + if (y0 * y1 > 0.0) { + return false; + } + let t = y0 / (y0 - y1); + let cross = mix(prev, cur, vec3(t)); + let r = length(vec2(cross.x, cross.z)); + return r >= uniforms.disk_inner && r <= uniforms.disk_outer; +} + +fn disk_color(pos: vec3, dir: vec3) -> vec3 { + let r = length(vec2(pos.x, pos.z)); + let phi = atan2(pos.z, pos.x); + + let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5); + let n = sin(phi * 8.0 + rot) * 0.5 + 0.5; + let n2 = sin(phi * 23.0 - rot * 1.7 + r * 2.0) * 0.5 + 0.5; + let noise = mix(n, n2, 0.4); + + let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner); + let tcol = mix(vec3(1.0, 0.95, 0.85), vec3(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0)); + + let falloff = 1.0 / pow(r / uniforms.disk_inner, 2.0); + + var col = tcol * (0.6 + 0.4 * noise) * falloff; + + let v_orbital = sqrt(uniforms.rs / (2.0 * r)); + let tangent = normalize(vec3(-sin(phi), 0.0, cos(phi))); + let vdotn = dot(tangent * v_orbital, -dir); + let gamma = 1.0 / sqrt(max(1.0 - v_orbital * v_orbital, 1e-4)); + var doppler = 1.0; + if (uniforms.doppler_enabled != 0u) { + let delta = 1.0 / (gamma * (1.0 - vdotn)); + doppler = pow(delta, 3.0) * uniforms.doppler_strength; + } + col *= doppler; + + return col * uniforms.disk_brightness; +} + +// --- planets --- +// `prev`/`cur` are in DISK-LOCAL space; planet centers are world space, so we +// rotate each center into disk-local space here. +fn planet_hit(prev: vec3, cur: vec3, dir: vec3) -> vec4 { + var nearest_t = 1e9; + var nearest_col = vec3(0.0); + var found = false; + for (var i: u32 = 0u; i < uniforms.planet_count; i = i + 1u) { + let s = planets[i]; + let center = rot_x(s.center.xyz, -uniforms.disk_tilt); + let radius = s.center.w; + let seg = cur - prev; + let oc = prev - center; + let a = dot(seg, seg); + let b = 2.0 * dot(oc, seg); + let c = dot(oc, oc) - radius * radius; + let disc = b * b - 4.0 * a * c; + if (disc < 0.0) { continue; } + let sq = sqrt(disc); + var t = (-b - sq) / (2.0 * a); + if (t < 0.0) { t = (-b + sq) / (2.0 * a); } + if (t >= 0.0 && t <= 1.0 && t < nearest_t) { + nearest_t = t; + let hit_pos = prev + seg * t; + let n = normalize(hit_pos - center); + let light_dir = normalize(vec3(0.5, 0.8, 0.3)); + let ndl = max(dot(n, light_dir), 0.0); + var col = s.color.xyz * (0.2 + 0.8 * ndl); + if (s.color.w > 0.5) { col = s.color.xyz; } + nearest_col = col; + found = true; + } + } + if (found) { + return vec4(nearest_col, 0.95); + } + return vec4(0.0, 0.0, 0.0, 0.0); +} + +// --- grid (Flamm's paraboloid) --- +fn flamm_depth(r: f32) -> f32 { + if (r <= uniforms.rs) { return 0.0; } + return -2.0 * sqrt(uniforms.rs * (r - uniforms.rs)); +} + +fn grid_hit(prev: vec3, cur: vec3) -> vec3 { + let r0 = length(vec2(prev.x, prev.z)); + let r1 = length(vec2(cur.x, cur.z)); + let z0_surf = flamm_depth(r0); + let z1_surf = flamm_depth(r1); + if ((prev.y - z0_surf) * (cur.y - z1_surf) > 0.0) { + return vec3(0.0); + } + var hit = vec3(0.0); + var found = false; + for (var s: i32 = 0; s < 8; s = s + 1) { + let f = f32(s + 1) / 8.0; + let p = mix(prev, cur, vec3(f)); + let r = length(vec2(p.x, p.z)); + let surf = flamm_depth(r); + if (abs(p.y - surf) < 0.3) { + hit = p; + found = true; + break; + } + } + if (!found) { return vec3(0.0); } + + let r = length(vec2(hit.x, hit.z)); + let phi = atan2(hit.z, hit.x); + let ring = smoothstep(0.06, 0.0, abs(fract(r * uniforms.grid_density * 0.5) - 0.5)); + let spoke = smoothstep(0.04, 0.0, abs(fract(phi * 6.0 / 6.283185) - 0.5)); + let grid = max(ring, spoke); + let fade = smoothstep(-15.0, -1.0, hit.y); + let col = vec3(0.15, 0.3, 0.6) * grid * fade; + return col * 0.5; +} + +// ====================== main ====================== @fragment fn fragment(in: VertexOutput) -> @location(0) vec4 { let aspect = uniforms.resolution.x / uniforms.resolution.y; @@ -51,13 +253,10 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { var d = normalize(rot_x(dir, -uniforms.disk_tilt)); // Total path length to integrate: enough to go from the camera, past the - // hole, and far enough beyond to count as escaped. We size dt so that - // `steps` steps cover this distance. (The original dt=|eye|/steps only - // traveled |eye| units total — never reaching capture or escape — so - // every ray fell through to accum=black.) + // hole, and far enough beyond to count as escaped. let eye_dist = length(uniforms.eye.xyz); - let escape_r = max(eye_dist * 2.0, 100.0); // "escaped" = clearly past the hole - let total_path = eye_dist + escape_r; // go in, through, and out + let escape_r = max(eye_dist * 2.0, 100.0); + let total_path = eye_dist + escape_r; let dt = total_path / f32(uniforms.steps); let steps = uniforms.steps; @@ -77,9 +276,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { // Rotate back to world for the sky/stars sample. let world_dir = normalize(rot_x(d, uniforms.disk_tilt)); var bg = vec3(0.0); - // Procedural stars are always layered in. bg += star_color(world_dir, uniforms.star_intensity); - // Optional cubemap skybox (gated so we never sample the fallback 1x1 texture). if (uniforms.skybox_intensity > 0.0) { bg += skybox_color(world_dir) * uniforms.skybox_intensity; } @@ -97,11 +294,10 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { let new_dir = normalize(d + (k1.ddir + 2.0*k2.ddir + 2.0*k3.ddir + k4.ddir) * dt / 6.0); if (disk_hit(prev, new_pos)) { - // Approximate the crossing point by interpolating to y=0. let ty = prev.y / (prev.y - new_pos.y); let hit = mix(prev, new_pos, vec3(ty)); let dc = disk_color(hit, new_dir); - let a = 0.85; // disk is nearly opaque + let a = 0.85; accum_color += (1.0 - accum_alpha) * dc * a; accum_alpha += (1.0 - accum_alpha) * a; if (accum_alpha > 0.99) { break; } @@ -117,7 +313,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { if (uniforms.grid_enabled != 0u) { let g = grid_hit(prev, new_pos); if (g.x + g.y + g.z > 0.0) { - accum_color += g; // additive + accum_color += g; } } diff --git a/assets/shaders/disk.wgsl b/assets/shaders/disk.wgsl deleted file mode 100644 index 4f2f68d..0000000 --- a/assets/shaders/disk.wgsl +++ /dev/null @@ -1,62 +0,0 @@ -// Disk plane is the xz-plane in world space, tilted by `disk_tilt` around the -// x-axis. We work in "disk-local" coordinates by rotating the ray. -#define_import_path singularity::disk - -// Rotate a vector around the X axis by angle a. -fn rot_x(v: vec3, a: f32) -> vec3 { - let c = cos(a); - let s = sin(a); - return vec3(v.x, c * v.y - s * v.z, s * v.y + c * v.z); -} - -// Returns true if the segment pos->pos+dir*dt crosses the disk plane (y=0) -// within radius [disk_inner, disk_outer]. (prev, cur are the segment endpoints.) -fn disk_hit(prev: vec3, cur: vec3) -> bool { - let y0 = prev.y; - let y1 = cur.y; - if (y0 * y1 > 0.0) { - return false; // same side, no crossing - } - // Linear interpolate to the crossing point. - let t = y0 / (y0 - y1); - let cross = mix(prev, cur, vec3(t)); - let r = length(vec2(cross.x, cross.z)); - return r >= uniforms.disk_inner && r <= uniforms.disk_outer; -} - -// Shade a disk hit: procedural texture + Doppler beaming + temperature color. -fn disk_color(pos: vec3, dir: vec3) -> vec3 { - let r = length(vec2(pos.x, pos.z)); - let phi = atan2(pos.z, pos.x); - - // Procedural noise: layered angular + radial, animated by rotation. - let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5); - let n = sin(phi * 8.0 + rot) * 0.5 + 0.5; - let n2 = sin(phi * 23.0 - rot * 1.7 + r * 2.0) * 0.5 + 0.5; - let noise = mix(n, n2, 0.4); - - // Temperature gradient: hotter (white-blue) near inner edge, cooler (orange-red) outer. - let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner); - let tcol = mix(vec3(1.0, 0.95, 0.85), vec3(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0)); - - // Falloff: brighter at inner edge. - let falloff = 1.0 / pow(r / uniforms.disk_inner, 2.0); - - var col = tcol * (0.6 + 0.4 * noise) * falloff; - - // Doppler beaming. Disk orbits Keplerian-ish: v ~ sqrt(Rs/(2r)). - let v_orbital = sqrt(uniforms.rs / (2.0 * r)); - // Orbital velocity direction (tangent) in the disk plane. - let tangent = normalize(vec3(-sin(phi), 0.0, cos(phi))); - // Scalar approximation: projection of orbital velocity onto ray direction. - let vdotn = dot(tangent * v_orbital, -dir); // toward viewer if positive - let gamma = 1.0 / sqrt(max(1.0 - v_orbital * v_orbital, 1e-4)); - var doppler = 1.0; - if (uniforms.doppler_enabled != 0u) { - let delta = 1.0 / (gamma * (1.0 - vdotn)); - doppler = pow(delta, 3.0) * uniforms.doppler_strength; - } - col *= doppler; - - return col * uniforms.disk_brightness; -} diff --git a/assets/shaders/geodesic_schwarzschild.wgsl b/assets/shaders/geodesic_schwarzschild.wgsl deleted file mode 100644 index 799899a..0000000 --- a/assets/shaders/geodesic_schwarzschild.wgsl +++ /dev/null @@ -1,42 +0,0 @@ -#define_import_path singularity::geodesic - -const R_ESCAPE: f32 = 1000.0; - -struct Deriv { - dpos: vec3, - ddir: vec3, -} - -fn deriv(pos: vec3, dir: vec3) -> Deriv { - let r = length(pos); - let rs = uniforms.rs; - let h = cross(pos, dir); - let h2 = dot(h, h); - let r5 = max(r * r * r * r * r, 1e-6); - let dpos = dir; - let accel = -1.5 * rs * h2 / r5 * pos; - return Deriv(dpos, accel); -} - -struct RayResult { - status: u32, - final_pos: vec3, - final_dir: vec3, -} - -fn classify_ray(start_pos: vec3, start_dir: vec3, steps: u32, dt: f32) -> RayResult { - var pos = start_pos; - var dir = start_dir; - for (var i: u32 = 0u; i < steps; i = i + 1u) { - let r = length(pos); - if (r < uniforms.rs) { return RayResult(1u, pos, dir); } - if (r > R_ESCAPE) { return RayResult(0u, pos, dir); } - let k1 = deriv(pos, dir); - let k2 = deriv(pos + k1.dpos * dt * 0.5, normalize(dir + k1.ddir * dt * 0.5)); - let k3 = deriv(pos + k2.dpos * dt * 0.5, normalize(dir + k2.ddir * dt * 0.5)); - let k4 = deriv(pos + k3.dpos * dt, normalize(dir + k3.ddir * dt)); - pos = pos + (k1.dpos + 2.0 * k2.dpos + 2.0 * k3.dpos + k4.dpos) * dt / 6.0; - dir = normalize(dir + (k1.ddir + 2.0 * k2.ddir + 2.0 * k3.ddir + k4.ddir) * dt / 6.0); - } - return RayResult(0u, pos, dir); -} diff --git a/assets/shaders/grid.wgsl b/assets/shaders/grid.wgsl deleted file mode 100644 index b415eb8..0000000 --- a/assets/shaders/grid.wgsl +++ /dev/null @@ -1,51 +0,0 @@ -// Flamm's paraboloid embedding: z(r) = 2*sqrt(Rs*(r - Rs)), opens DOWNWARD -// (negative y in disk-local space). Dips below the disk toward the center — -// the classic gravity-well visualization. Traced through curved spacetime, so -// grid lines near the hole bend dramatically. -#define_import_path singularity::grid - -fn flamm_depth(r: f32) -> f32 { - if (r <= uniforms.rs) { return 0.0; } - return -2.0 * sqrt(uniforms.rs * (r - uniforms.rs)); -} - -// Returns additive grid color if the segment prev->cur crosses the Flamm -// paraboloid surface; returns black otherwise. `prev`/`cur` are disk-local. -fn grid_hit(prev: vec3, cur: vec3) -> vec3 { - // Sample the paraboloid at the segment endpoints; if the segment crosses it, - // find an approximate crossing by sampling. - let r0 = length(vec2(prev.x, prev.z)); - let r1 = length(vec2(cur.x, cur.z)); - let z0_surf = flamm_depth(r0); - let z1_surf = flamm_depth(r1); - // Did the ray's y cross the surface y between endpoints? - if ((prev.y - z0_surf) * (cur.y - z1_surf) > 0.0) { - return vec3(0.0); - } - // Crossing: linear-search for the crossing point. - var hit = vec3(0.0); - var found = false; - for (var s: i32 = 0; s < 8; s = s + 1) { - let f = f32(s + 1) / 8.0; - let p = mix(prev, cur, vec3(f)); - let r = length(vec2(p.x, p.z)); - let surf = flamm_depth(r); - if (abs(p.y - surf) < 0.3) { - hit = p; - found = true; - break; - } - } - if (!found) { return vec3(0.0); } - - // Polar grid pattern from (r, phi). - let r = length(vec2(hit.x, hit.z)); - let phi = atan2(hit.z, hit.x); - let ring = smoothstep(0.06, 0.0, abs(fract(r * uniforms.grid_density * 0.5) - 0.5)); - let spoke = smoothstep(0.04, 0.0, abs(fract(phi * 6.0 / 6.283185) - 0.5)); - let grid = max(ring, spoke); - // Fade with depth so the grid reads as "below" the hole. - let fade = smoothstep(-15.0, -1.0, hit.y); - let col = vec3(0.15, 0.3, 0.6) * grid * fade; - return col * 0.5; // additive, low intensity -} diff --git a/assets/shaders/planets.wgsl b/assets/shaders/planets.wgsl deleted file mode 100644 index c71958f..0000000 --- a/assets/shaders/planets.wgsl +++ /dev/null @@ -1,57 +0,0 @@ -#define_import_path singularity::planets - -#import singularity::disk::rot_x - -struct SphereData { - center: vec4, // xyz = center (world space), w = radius - color: vec4, // xyz = color, w = emissive flag (u32 reinterpreted; we just check > 0.5) -}; - -// The storage binding is declared here as part of the planets module; it lives -// in the material's bind group (group 2 = #{MATERIAL_BIND_GROUP}). -@group(#{MATERIAL_BIND_GROUP}) @binding(3) var planets: array; - -// Test the segment prev->cur against all planets. Returns hit color & alpha, -// or (0,0,0,0) if no hit. `dir` is the ray direction (for shading). -// `prev`/`cur` are in DISK-LOCAL space (the caller rotates eye/dir by -disk_tilt -// before integrating), so we rotate each planet's world-space center into -// disk-local space here for a consistent intersection test. -fn planet_hit(prev: vec3, cur: vec3, dir: vec3) -> vec4 { - var nearest_t = 1e9; - var nearest_col = vec3(0.0); - var found = false; - for (var i: u32 = 0u; i < uniforms.planet_count; i = i + 1u) { - let s = planets[i]; - // Planet centers are stored in world space; rotate into disk-local space - // to match the ray's coordinate system. - let center = rot_x(s.center.xyz, -uniforms.disk_tilt); - let radius = s.center.w; - // Ray-sphere intersection for the segment. - let seg = cur - prev; - let oc = prev - center; - let a = dot(seg, seg); - let b = 2.0 * dot(oc, seg); - let c = dot(oc, oc) - radius * radius; - let disc = b * b - 4.0 * a * c; - if (disc < 0.0) { continue; } - let sq = sqrt(disc); - var t = (-b - sq) / (2.0 * a); - if (t < 0.0) { t = (-b + sq) / (2.0 * a); } - if (t >= 0.0 && t <= 1.0 && t < nearest_t) { - nearest_t = t; - let hit_pos = prev + seg * t; - let n = normalize(hit_pos - center); - // Lambert shading from a fixed light direction. - let light_dir = normalize(vec3(0.5, 0.8, 0.3)); - let ndl = max(dot(n, light_dir), 0.0); - var col = s.color.xyz * (0.2 + 0.8 * ndl); - if (s.color.w > 0.5) { col = s.color.xyz; } // emissive - nearest_col = col; - found = true; - } - } - if (found) { - return vec4(nearest_col, 0.95); - } - return vec4(0.0, 0.0, 0.0, 0.0); -} diff --git a/assets/shaders/ray_gen.wgsl b/assets/shaders/ray_gen.wgsl deleted file mode 100644 index e4f0713..0000000 --- a/assets/shaders/ray_gen.wgsl +++ /dev/null @@ -1,16 +0,0 @@ -// Builds a world-space camera ray direction for the current pixel. -// `uv` is the pixel coordinate normalized to [-1,1] with aspect correction. -#define_import_path singularity::ray_gen - -fn ray_direction(uv: vec2) -> vec3 { - // NOTE: `fov` is packed into the `.w` of `up` in BlackHoleUniforms - // (the Rust struct lays out `up: Vec3` + `fov: f32` as one vec4 block). - // The WGSL struct must stay exactly as-is per the task spec, so read fov - // from `uniforms.up.w` rather than a separate `uniforms.fov` field. - let tan_half_fov = tan(uniforms.up.w * 0.5); - let dir = - normalize(uniforms.forward.xyz) - + uniforms.right.xyz * (uv.x * tan_half_fov) - + uniforms.up.xyz * (uv.y * tan_half_fov); - return normalize(dir); -} diff --git a/assets/shaders/skybox.wgsl b/assets/shaders/skybox.wgsl deleted file mode 100644 index 1d07ff0..0000000 --- a/assets/shaders/skybox.wgsl +++ /dev/null @@ -1,9 +0,0 @@ -#define_import_path singularity::skybox - -@group(#{MATERIAL_BIND_GROUP}) @binding(1) var skybox: texture_cube; -@group(#{MATERIAL_BIND_GROUP}) @binding(2) var skybox_sampler: sampler; - -// Sample the cubemap along a world-space direction. Caller gates on skybox_intensity. -fn skybox_color(dir: vec3) -> vec3 { - return textureSample(skybox, skybox_sampler, dir).rgb; -} diff --git a/assets/shaders/stars.wgsl b/assets/shaders/stars.wgsl deleted file mode 100644 index 068fb39..0000000 --- a/assets/shaders/stars.wgsl +++ /dev/null @@ -1,29 +0,0 @@ -// Hash-based procedural stars on the unit sphere. Returns RGB radiance. -#define_import_path singularity::stars - -fn hash13(p: vec3) -> f32 { - var q = vec3(dot(p, vec3(127.1, 311.7, 74.7)), - dot(p, vec3(269.5, 183.3, 246.1)), - dot(p, vec3(113.5, 271.9, 124.6))); - let h = fract(sin(q) * 43758.5453); - return h.x; -} - -fn star_color(dir: vec3, intensity: f32) -> vec3 { - // Divide the sphere into cells; a cell gets a star if its hash passes a threshold. - let scale = 80.0; - let cell = floor(dir * scale); - let h = hash13(cell); - let threshold = 0.985; // ~1.5% of cells hold a star - if (h > threshold) { - // Brightness from the hash remainder. - let b = (h - threshold) / (1.0 - threshold); - let col = mix(vec3(0.6, 0.7, 1.0), vec3(1.0, 0.9, 0.7), b); - // Soften the star with the fractional position inside the cell. - let f = abs(dir * scale - cell); - let d = max(f.x, max(f.y, f.z)); - let falloff = smoothstep(0.5, 0.0, d); - return col * b * falloff * 3.0 * intensity; - } - return vec3(0.0); -} diff --git a/src/render/material.rs b/src/render/material.rs index 391e846..1d89da8 100644 --- a/src/render/material.rs +++ b/src/render/material.rs @@ -95,7 +95,14 @@ pub struct BlackHoleMaterial { pub uniforms: BlackHoleUniforms, // Texture at binding 1 + its matching sampler at binding 2. The derive // requires the texture and sampler attributes to live on the same field. - #[texture(1)] + // `dimension = "cube"` is REQUIRED: skybox.wgsl declares this binding as + // `texture_cube`. The derive defaults to D2, which made the bind-group + // layout (D2) disagree with the shader (Cube) — the pipeline failed to + // specialize and the fullscreen quad silently drew nothing, leaving only + // the camera clear color. Matching the dimension to Cube lets the pipeline + // compile; when no cubemap is set, Bevy binds its 1x1 cube fallback (gated + // out by `skybox_intensity > 0` in the shader anyway). + #[texture(1, dimension = "cube")] #[sampler(2)] pub skybox: Option>, #[storage(3, read_only)] diff --git a/src/scene/planets.rs b/src/scene/planets.rs index 49f5c9b..a0b10d8 100644 --- a/src/scene/planets.rs +++ b/src/scene/planets.rs @@ -12,16 +12,25 @@ pub struct Planet { pub emissive: bool, } -/// Collects all Planet components, builds a fixed-size Vec (padded -/// to MAX_PLANETS), wraps it in a ShaderBuffer, and ensures every BlackHoleMaterial -/// points its `planets` handle at that buffer. Also updates planet_count in params. +/// Collects all Planet components, writes them into the shared MAX_PLANETS-sized +/// `ShaderBuffer` that the material already binds, and updates `planet_count`. /// -/// NOTE: the material field is `Handle` (Bevy 0.19 AsBindGroup -/// requirement). We create one ShaderBuffer asset and have all materials share it. +/// CRITICAL: we must NOT allocate a new `ShaderBuffer` (and a new handle) each +/// frame. The `#[storage(3, read_only)]` binding resolves the handle via +/// `RenderAssets::get(handle)` and returns +/// `AsBindGroupError::RetryNextUpdate` if the GPU asset for *that exact handle* +/// isn't ready yet. A freshly-added asset has no GPU asset yet, so reassigning +/// the handle every frame makes the fullscreen quad's draw get skipped every +/// frame — the screen shows only the camera clear color (grey). +/// +/// Instead, mutate the existing asset in place. `GpuShaderBuffer::prepare_asset` +/// (bevy_render 0.19 `storage.rs`) sees the changed CPU data, reuses the same +/// GPU buffer, and `write_buffer`s the new contents — the handle stays stable, +/// the GPU asset stays ready, and the draw proceeds. pub fn upload_planets( planets: Query<&Planet>, mut params: ResMut, - mut materials: ResMut>, + materials: Res>, mut buffers: ResMut>, ) { let mut data: Vec = planets @@ -41,10 +50,15 @@ pub fn upload_planets( data.resize(MAX_PLANETS, SphereData::default()); params.planet_count = planets.iter().count().min(MAX_PLANETS) as u32; - // Build (or rebuild) the ShaderBuffer and share its handle across materials. - let buffer = ShaderBuffer::from(data); - for (_, mat) in materials.iter_mut() { - mat.planets = buffers.add(buffer.clone()); + // Write into the existing buffer asset(s) the materials already reference. + // The startup system pre-creates exactly one such buffer; we find it by the + // materials' handles and mutate in place — never reallocate the handle. + // set_data moves a Vec (encase treats Vec as a runtime-sized array), + // matching the official bevy 0.19 storage_buffer example. + for (_, mat) in materials.iter() { + if let Some(mut buffer) = buffers.get_mut(&mat.planets) { + buffer.set_data(data.clone()); + } } }