fix: make the black hole actually render (was stuck on grey clear color)

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<f32>
   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.
This commit is contained in:
xfy 2026-07-13 16:43:01 +08:00
parent 0b158c57d3
commit 4556376b4a
10 changed files with 249 additions and 298 deletions

View File

@ -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<f32>,
@ -38,6 +39,207 @@ struct BlackHoleUniforms {
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms;
// ---------- planets storage (binding 3) ----------
struct SphereData {
center: vec4<f32>, // xyz = center (world space), w = radius
color: vec4<f32>, // xyz = color, w = emissive flag
};
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var<storage, read> planets: array<SphereData>;
// ---------- optional cubemap skybox (bindings 1 & 2) ----------
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var skybox: texture_cube<f32>;
@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<f32>, a: f32) -> vec3<f32> {
let c = cos(a);
let s = sin(a);
return vec3<f32>(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<f32>) -> vec3<f32> {
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>) -> f32 {
var q = vec3<f32>(dot(p, vec3<f32>(127.1, 311.7, 74.7)),
dot(p, vec3<f32>(269.5, 183.3, 246.1)),
dot(p, vec3<f32>(113.5, 271.9, 124.6)));
let h = fract(sin(q) * 43758.5453);
return h.x;
}
fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
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<f32>(0.6, 0.7, 1.0), vec3<f32>(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<f32>(0.0);
}
// --- skybox ---
fn skybox_color(dir: vec3<f32>) -> vec3<f32> {
return textureSample(skybox, skybox_sampler, dir).rgb;
}
// --- geodesic ---
struct Deriv {
dpos: vec3<f32>,
ddir: vec3<f32>,
}
fn deriv(pos: vec3<f32>, dir: vec3<f32>) -> 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<f32>, cur: vec3<f32>) -> 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<f32>(t));
let r = length(vec2<f32>(cross.x, cross.z));
return r >= uniforms.disk_inner && r <= uniforms.disk_outer;
}
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
let r = length(vec2<f32>(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<f32>(1.0, 0.95, 0.85), vec3<f32>(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<f32>(-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<f32>, cur: vec3<f32>, dir: vec3<f32>) -> vec4<f32> {
var nearest_t = 1e9;
var nearest_col = vec3<f32>(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<f32>(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<f32>(nearest_col, 0.95);
}
return vec4<f32>(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<f32>, cur: vec3<f32>) -> vec3<f32> {
let r0 = length(vec2<f32>(prev.x, prev.z));
let r1 = length(vec2<f32>(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<f32>(0.0);
}
var hit = vec3<f32>(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<f32>(f));
let r = length(vec2<f32>(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<f32>(0.0); }
let r = length(vec2<f32>(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<f32>(0.15, 0.3, 0.6) * grid * fade;
return col * 0.5;
}
// ====================== main ======================
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let aspect = uniforms.resolution.x / uniforms.resolution.y;
@ -51,13 +253,10 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
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<f32> {
// Rotate back to world for the sky/stars sample.
let world_dir = normalize(rot_x(d, uniforms.disk_tilt));
var bg = vec3<f32>(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<f32> {
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<f32>(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<f32> {
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;
}
}

View File

@ -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<f32>, a: f32) -> vec3<f32> {
let c = cos(a);
let s = sin(a);
return vec3<f32>(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<f32>, cur: vec3<f32>) -> 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<f32>(t));
let r = length(vec2<f32>(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<f32>, dir: vec3<f32>) -> vec3<f32> {
let r = length(vec2<f32>(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<f32>(1.0, 0.95, 0.85), vec3<f32>(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<f32>(-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;
}

View File

@ -1,42 +0,0 @@
#define_import_path singularity::geodesic
const R_ESCAPE: f32 = 1000.0;
struct Deriv {
dpos: vec3<f32>,
ddir: vec3<f32>,
}
fn deriv(pos: vec3<f32>, dir: vec3<f32>) -> 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<f32>,
final_dir: vec3<f32>,
}
fn classify_ray(start_pos: vec3<f32>, start_dir: vec3<f32>, 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);
}

View File

@ -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<f32>, cur: vec3<f32>) -> vec3<f32> {
// Sample the paraboloid at the segment endpoints; if the segment crosses it,
// find an approximate crossing by sampling.
let r0 = length(vec2<f32>(prev.x, prev.z));
let r1 = length(vec2<f32>(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<f32>(0.0);
}
// Crossing: linear-search for the crossing point.
var hit = vec3<f32>(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<f32>(f));
let r = length(vec2<f32>(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<f32>(0.0); }
// Polar grid pattern from (r, phi).
let r = length(vec2<f32>(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<f32>(0.15, 0.3, 0.6) * grid * fade;
return col * 0.5; // additive, low intensity
}

View File

@ -1,57 +0,0 @@
#define_import_path singularity::planets
#import singularity::disk::rot_x
struct SphereData {
center: vec4<f32>, // xyz = center (world space), w = radius
color: vec4<f32>, // 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<storage, read> planets: array<SphereData>;
// 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<f32>, cur: vec3<f32>, dir: vec3<f32>) -> vec4<f32> {
var nearest_t = 1e9;
var nearest_col = vec3<f32>(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<f32>(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<f32>(nearest_col, 0.95);
}
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}

View File

@ -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<f32>) -> vec3<f32> {
// 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);
}

View File

@ -1,9 +0,0 @@
#define_import_path singularity::skybox
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var skybox: texture_cube<f32>;
@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<f32>) -> vec3<f32> {
return textureSample(skybox, skybox_sampler, dir).rgb;
}

View File

@ -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>) -> f32 {
var q = vec3<f32>(dot(p, vec3<f32>(127.1, 311.7, 74.7)),
dot(p, vec3<f32>(269.5, 183.3, 246.1)),
dot(p, vec3<f32>(113.5, 271.9, 124.6)));
let h = fract(sin(q) * 43758.5453);
return h.x;
}
fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
// 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<f32>(0.6, 0.7, 1.0), vec3<f32>(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<f32>(0.0);
}

View File

@ -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<f32>`. 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<Handle<Image>>,
#[storage(3, read_only)]

View File

@ -12,16 +12,25 @@ pub struct Planet {
pub emissive: bool,
}
/// Collects all Planet components, builds a fixed-size Vec<SphereData> (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<ShaderBuffer>` (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<GpuShaderBuffer>::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<crate::params::BlackHoleParams>,
mut materials: ResMut<Assets<crate::render::material::BlackHoleMaterial>>,
materials: Res<Assets<crate::render::material::BlackHoleMaterial>>,
mut buffers: ResMut<Assets<ShaderBuffer>>,
) {
let mut data: Vec<SphereData> = 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<T> (encase treats Vec<T> 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());
}
}
}