Use a procedural raymarched tunnel shader (based on the Shadertoy "cavern/tunnel" style shader) as a live, data-driven "scan cutscene" when the player scans an asteroid in the space game. The camera flies down a procedurally generated tunnel (framed as a probe bore-view / penetrating-scan visualization), slows into slow motion and pauses on mineral deposits the server says are inside, then accelerates onward. Replaces the need for pre-rendered cutscene video entirely.
A working prototype shader already exists (see Prototype below): it adds gold "nugget" deposits as real SDF geometry with metallic material + veins, a time-warped camera schedule (cruise -> slow-mo approach -> ~3s pause on the find -> pull away, looping), a focus value that steers the camera at the deposit, and a warm spotlight that fades in over the find.
Why (feasibility assessment)
Video cost: a 10-15s cutscene at 720p+ is ~5-20 MB per clip, fixed resolution, and shows exactly one thing. Reflecting actual scan results (3 gold deposits vs. 1 iron vein vs. nothing) via video needs a combinatorial clip library, or a single generic clip players see through immediately.
Shader cost: a few KB of GLSL + 1-2 small tileable textures (likely already in the asset bundle). Native resolution, no compression artifacts, every parameter live.
Verdict: build it. This is the category of problem procedural rendering exists for. The only genuine risk is fragment-shader cost on low-end devices (see Risks).
How server data drives it
Everything maps cleanly onto uniforms:
Deposit list: positions, sizes, count, mineral type -> drives geometry (getDeposit()), material color, and the camera dwell schedule.
Mineral color: gold vs. copper-green vs. ice-blue is one vec3 swap + maybe a specular exponent.
Tunnel character: square / round / Minkowski profiles (variants already exist in the original shader's comments), path frequency, texture choice, fog color.
Per-asteroid seed drives the hash functions so the same asteroid always produces the same scan — determinism is a feature for player trust.
Camera schedule generator: server JSON (list of finds) -> phase timeline. Dwell on each find, cruise through empty stretches; an "empty scan" reads visually distinct from a "rich scan" for free — something video can't do.
Improvement suggestions (from prototype review)
Pass the current deposit as a uniform instead of recomputing getDeposit() (path + hash) inside map(), which runs ~128+ times/pixel. Meaningful perf win, and it's what server-driven data requires anyway.
Performance tiers: render to half/quarter-res framebuffer and upscale (raymarched cave visuals survive upscaling well); reduce raymarch steps and AO samples on low tier.
Diegetic framing: present as a scanner monitor that doesn't fill the screen — hides perf cost and sells the fiction. Add scanline overlay, HUD elements, depth ticker, slight signal noise (near-zero cost, big believability win).
Variation matrix to fight repetitiveness: 2-3 tunnel profiles x a few texture/palette sets x path character. Make the cutscene skippable/compressible after the first few viewings.
Generalized camera schedule: build cameraZ() phases from the server's find list rather than a fixed cycle; consider a faster "disappointed pull-through" for empty scans.
Multiple mineral types per scan (different deposit colors/materials in one flythrough).
Offline capture fallback: the shader can be rendered offline at any quality and captured to video for marketing/story beats — shader path keeps video as the special case, not the default.
Risks / caveats
Fragment shader is heavy: 128 raymarch steps; each map() is non-trivial; +5 map calls for AO, +10 for curvature, +6 for normals, and 12-24 texture fetches for triplanar bump mapping. Fine on desktop GPU at 1080p; WebGL on integrated/mobile GPUs may drop below 30fps at full screen/full res. Mitigations in suggestions 1-3.
Reads as a tunnel, not an asteroid interior: framing problem, not a rendering problem — bore-view / synthetic-aperture scan fiction solves it (suggestion 3).
Repetitiveness if players scan constantly — needs the variation matrix (suggestion 4) plus skippability.
Porting effort: Shadertoy -> own GL context is mostly boilerplate (fullscreen quad, iTime/iResolution/iChannel -> uniforms), ~1-2 days incl. uniform plumbing and quality tiers. The camera-schedule generator is the only real new code, and it's small.
Acceptance criteria
Shader ported off Shadertoy into the app's GL context (fullscreen quad + uniforms)
Deposits (position/size/count/mineral color) driven by server response via uniforms, not hardcoded
Camera schedule generated from the find list; slow-mo + pause on each find; distinct empty-scan behavior
Deterministic per-asteroid seed (same asteroid = same scan)
Frame-rate spot check on the worst target device at intended viewport size — validate this FIRST before investing further
Skippable after first viewing
Prototype
Working prototype (hardcoded "server data": one deposit per 20s / 60-unit cycle, endlessly looping):
Full prototype shader (GLSL, Shadertoy-ready; iChannel0 = wall rock texture, iChannel1 = floor texture)
#define PI 3.1415926535898#define FH 1.0 // Floor height. Set it to 2.0 to get rid of the floor.// ---------------------------------------------------------------------// GOLD: "Server data" -- hardcoded for now. In production, these become// uniforms driven by the server response (deposit position, size, type).// One deposit is found per cycle, endlessly.#define CYCLE_T 20.0 // Seconds per find-cycle.#define CYCLE_DIST 60.0 // Distance the probe travels per cycle.#define DEPOSIT_Z 50.0 // Where within each cycle the deposit sits.constvec3GOLD=vec3(1.0,0.72,0.25);// ---------------------------------------------------------------------// Grey scale.floatgetGrey(vec3p){returnp.x*0.299+p.y*0.587+p.z*0.114;}// Non-standard vec3-to-vec3 hash function.vec3hash33(vec3p){floatn=sin(dot(p,vec3(7,157,113)));returnfract(vec3(2097152,262144,32768)*n);}// 2x2 matrix rotation.mat2rot2(floata){floatc=cos(a);floats=sin(a);returnmat2(c,s,-s,c);}// Tri-Planar blending function. Based on an old Nvidia tutorial.vec3tex3D(sampler2Dtex,invec3p,invec3n){n=max((abs(n)-0.2)*7.,0.001);n/=(n.x+n.y+n.z);return(texture(tex,p.yz)*n.x+texture(tex,p.zx)*n.y+texture(tex,p.xy)*n.z).xyz;}// Triangle function.vec3tri(invec3x){returnabs(x-floor(x)-.5);}// The function used to perturb the walls of the cavern.floatsurfFunc(invec3p){returndot(tri(p*0.5+tri(p*0.25).yzx),vec3(0.666));}// The path is a 2D sinusoid that varies over time.vec2path(infloatz){floats=sin(z/24.)*cos(z/12.);returnvec2(s*12.,0.);}// ---------------------------------------------------------------------// GOLD: Deposit descriptor for whatever cycle the given Z falls in.// Returns xyz = deposit center, w = deposit radius. The hash gives each// find a slightly different position and size -- exactly the values a// server would supply instead.vec4getDeposit(floatzRef){floatcy=floor(zRef/CYCLE_DIST);vec3h=hash33(vec3(cy+1.0,cy+57.0,cy+113.0));floatdz=cy*CYCLE_DIST+DEPOSIT_Z;// Sits on the floor, near the tunnel center, roughly nugget-sized.returnvec4(path(dz).x+(h.x-0.5)*1.5,-FH+0.3+h.y*0.15,dz,0.4+h.z*0.2);}// GOLD: How "golden" a surface point is: a solid core on the nugget// itself, plus noisy veins bleeding into the surrounding rock.floatgoldMask(vec3p){vec4dep=getDeposit(p.z);floatd=length(p-dep.xyz);floatcore=smoothstep(dep.w+0.45,dep.w*0.5,d);floatveins=dot(tri(p*3.0+tri(p*1.5).yzx),vec3(0.666));floathalo=smoothstep(dep.w+1.6,dep.w,d)*smoothstep(0.42,0.18,veins);returnclamp(core+halo*0.85,0.,1.);}// GOLD: Time-warped camera distance. Cruise -> slow-motion approach ->// pause on the deposit -> accelerate away. Repeats every CYCLE_T seconds.// "focus" ramps 0..1 as the probe locks onto the find; it drives the// camera aim and the spotlight.floatcameraZ(infloattime,outfloatfocus){floatcyc=floor(time/CYCLE_T);floatt=time-cyc*CYCLE_T;floatz;if(t<9.0){// Cruise at full speed (5 units/sec, same feel as the original).z=t/9.0*45.0;focus=smoothstep(4.0,9.0,t)*0.4;// Deposit "detected" ahead.}elseif(t<14.0){// Slow-motion approach: ease-out so the probe decelerates// as it comes right up to the deposit.floatu=(t-9.0)/5.0;u=1.0-(1.0-u)*(1.0-u);z=mix(45.0,48.6,u);focus=0.4+0.6*(t-9.0)/5.0;}elseif(t<17.0){// Paused: barely drifting, spotlighting the find.z=48.6+(t-14.0)*0.03;focus=1.0;}else{// Resume the descent, smoothly back up to speed.floatu=(t-17.0)/3.0;z=mix(48.69,60.0,u*u*(3.0-2.0*u));focus=1.0-u;}returncyc*CYCLE_DIST+z;}// ---------------------------------------------------------------------// Standard tunnel distance function with perturbation and a floor.floatmap(vec3p){floatsf=surfFunc(p-vec3(0,cos(p.z/3.)*.15,0));// Square tunnel.vec2tun=abs(p.xy-path(p.z))*vec2(0.5,0.7071);floatn=1.-max(tun.x,tun.y)+(0.5-sf);n=min(n,p.y+FH);// GOLD: Blend an actual lumpy nugget into the scene geometry, so// the deposit physically protrudes from the floor and catches light,// AO and curvature shading like the rest of the cavern.vec4dep=getDeposit(p.z);floatnug=length(p-dep.xyz)-dep.w*(0.8+sf*0.5);returnmin(n,nug);}// Texture bump mapping.vec3doBumpMap(sampler2Dtex,invec3p,invec3nor,floatbumpfactor){constfloateps=0.001;floatref=getGrey(tex3D(tex,p,nor));vec3grad=vec3(getGrey(tex3D(tex,vec3(p.x-eps,p.y,p.z),nor))-ref,getGrey(tex3D(tex,vec3(p.x,p.y-eps,p.z),nor))-ref,getGrey(tex3D(tex,vec3(p.x,p.y,p.z-eps),nor))-ref)/eps;grad-=nor*dot(nor,grad);returnnormalize(nor+grad*bumpfactor);}// Surface normal.vec3getNormal(invec3p){constfloateps=0.001;returnnormalize(vec3(map(vec3(p.x+eps,p.y,p.z))-map(vec3(p.x-eps,p.y,p.z)),map(vec3(p.x,p.y+eps,p.z))-map(vec3(p.x,p.y-eps,p.z)),map(vec3(p.x,p.y,p.z+eps))-map(vec3(p.x,p.y,p.z-eps))));}// Based on original by IQ.floatcalculateAO(vec3p,vec3n){constfloatAO_SAMPLES=5.0;floatr=0.0,w=1.0,d;for(floati=1.0;i<AO_SAMPLES+1.1;i++){d=i/AO_SAMPLES;r+=w*(d-map(p+n*d));w*=0.5;}return1.0-clamp(r,0.0,1.0);}// Cheap curvature, by Shadertoy user Nimitz.floatcurve(invec3p,infloatw){vec2e=vec2(-1.,1.)*w;floatt1=map(p+e.yxx),t2=map(p+e.xxy);floatt3=map(p+e.xyx),t4=map(p+e.yyy);return0.125/(w*w)*(t1+t2+t3+t4-4.*map(p));}voidmainImage(outvec4fragColor,invec2fragCoord){// Screen coordinates.vec2uv=(fragCoord-iResolution.xy*0.5)/iResolution.y;// GOLD: Camera position comes from the time-warped schedule now.floatfocus;vec3camPos=vec3(0.0,0.0,cameraZ(iTime,focus));vec3lookAt=camPos+vec3(0.0,0.1,0.5);// "Look At" position.vec3light_pos=camPos+vec3(0.0,0.125,-0.125);vec3light_pos2=camPos+vec3(0.0,0.0,6.0);// Sending the camera, "look at," and light vectors down the tunnel.lookAt.xy+=path(lookAt.z);camPos.xy+=path(camPos.z);light_pos.xy+=path(light_pos.z);light_pos2.xy+=path(light_pos2.z);// GOLD: The upcoming deposit, plus a warm spotlight hovering over it.// As "focus" ramps up, the camera aims at the find.vec4dep=getDeposit(camPos.z);vec3light_pos3=dep.xyz+vec3(0.0,1.2,-1.5);lookAt=mix(lookAt,dep.xyz,focus*0.5);floatFOV=PI/3.;// FOV - Field of view.vec3forward=normalize(lookAt-camPos);vec3right=normalize(vec3(forward.z,0.,-forward.x));vec3up=cross(forward,right);// rd - Ray direction.vec3rd=normalize(forward+FOV*uv.x*right+FOV*uv.y*up);// Swiveling the camera from left to right when turning corners.rd.xy=rot2(path(lookAt.z).x/32.)*rd.xy;// Standard ray marching routine.floatt=0.0,dt;for(inti=0;i<128;i++){dt=map(camPos+rd*t);if(dt<0.005||t>150.){break;}t+=dt*0.75;}vec3sceneCol=vec3(0.);// The ray has effectively hit the surface, so light it up.if(dt<0.005){vec3sp=t*rd+camPos;vec3sn=getNormal(sp);constfloattSize0=1./1.;constfloattSize1=1./4.;if(sp.y<-(FH-0.005))sn=doBumpMap(iChannel1,sp*tSize1,sn,0.025);// Floor.elsesn=doBumpMap(iChannel0,sp*tSize0,sn,0.025);// Walls.floatao=calculateAO(sp,sn);vec3ld=light_pos-sp;vec3ld2=light_pos2-sp;floatdistlpsp=max(length(ld),0.001);floatdistlpsp2=max(length(ld2),0.001);ld/=distlpsp;ld2/=distlpsp2;floatatten=min(1./(distlpsp)+1./(distlpsp2),1.);floatambience=0.25;floatdiff=max(dot(sn,ld),0.0);floatdiff2=max(dot(sn,ld2),0.0);floatspec=pow(max(dot(reflect(-ld,sn),-rd),0.0),8.);floatspec2=pow(max(dot(reflect(-ld2,sn),-rd),0.0),8.);floatcrv=clamp(curve(sp,0.125)*0.5+0.5,.0,1.);floatfre=pow(clamp(dot(sn,rd)+1.,.0,1.),1.);vec3texCol;if(sp.y<-(FH-0.005))texCol=tex3D(iChannel1,sp*tSize1,sn);// Floor.elsetexCol=tex3D(iChannel0,sp*tSize0,sn);// Walls.floatshading=crv*0.5+0.5;// Glow.sceneCol=getGrey(texCol)*((diff+diff2)*0.75+ambience*0.25)+(spec+spec2)*texCol*2.+fre*crv*texCol.zyx*2.;// GOLD: Metallic gold response for the deposit and its veins.// Tighter, hotter speculars so it reads as metal, not rock.floatg=goldMask(sp);if(g>0.001){floatgspec=pow(max(dot(reflect(-ld2,sn),-rd),0.0),32.);vec3goldScene=GOLD*((diff+diff2)*0.85+ambience*0.3)+GOLD*(spec+spec2)*3.0+vec3(1.0,0.9,0.6)*gspec*4.0;sceneCol=mix(sceneCol,goldScene,g);}// Shading.sceneCol*=atten*shading*ao;// Drawing the lines on the walls.sceneCol*=clamp(1.-abs(curve(sp,0.0125)),.0,1.);// GOLD: Spotlight over the deposit -- fades in with "focus," so the// find gets lit up as the probe slows and pauses on it.if(focus>0.001){vec3ld3=light_pos3-sp;floatdistlpsp3=max(length(ld3),0.001);ld3/=distlpsp3;floatdiff3=max(dot(sn,ld3),0.0);floatspec3=pow(max(dot(reflect(-ld3,sn),-rd),0.0),16.);floatatten3=focus*min(2.0/(distlpsp3*distlpsp3),1.0);sceneCol+=(diff3*vec3(1.0,0.85,0.5)*getGrey(texCol)+spec3*vec3(1.0,0.9,0.6))*atten3*ao;}// GOLD: A soft emissive pulse on the gold itself while focused,// so the deposit visibly "registers" as a find during the pause.sceneCol+=GOLD*g*(0.08+0.3*focus*(0.5+0.5*sin(iTime*3.0)));}fragColor=vec4(clamp(sceneCol,0.,1.),1.0);}
Prototype timeline knobs: phases in cameraZ() — seconds 0-9 cruise, 9-14 slow-motion approach, 14-17 full pause, 17-20 pull away; CYCLE_T/CYCLE_DIST retime it. Camera stops ~1.3 units short of the deposit so it fills the view. The getDeposit() vec4 (position + radius) is exactly the payload the server would send, plus mineral color.
## Summary
Use a procedural raymarched tunnel shader (based on the Shadertoy "cavern/tunnel" style shader) as a **live, data-driven "scan cutscene"** when the player scans an asteroid in the space game. The camera flies down a procedurally generated tunnel (framed as a probe bore-view / penetrating-scan visualization), **slows into slow motion and pauses on mineral deposits** the server says are inside, then accelerates onward. Replaces the need for pre-rendered cutscene video entirely.
A working prototype shader already exists (see **Prototype** below): it adds gold "nugget" deposits as real SDF geometry with metallic material + veins, a time-warped camera schedule (cruise -> slow-mo approach -> ~3s pause on the find -> pull away, looping), a focus value that steers the camera at the deposit, and a warm spotlight that fades in over the find.
## Why (feasibility assessment)
**Video cost:** a 10-15s cutscene at 720p+ is ~5-20 MB *per clip*, fixed resolution, and shows exactly one thing. Reflecting actual scan results (3 gold deposits vs. 1 iron vein vs. nothing) via video needs a combinatorial clip library, or a single generic clip players see through immediately.
**Shader cost:** a few KB of GLSL + 1-2 small tileable textures (likely already in the asset bundle). Native resolution, no compression artifacts, every parameter live.
**Verdict: build it.** This is the category of problem procedural rendering exists for. The only genuine risk is fragment-shader cost on low-end devices (see Risks).
## How server data drives it
Everything maps cleanly onto uniforms:
- **Deposit list**: positions, sizes, count, mineral type -> drives geometry (`getDeposit()`), material color, and the camera dwell schedule.
- **Mineral color**: gold vs. copper-green vs. ice-blue is one `vec3` swap + maybe a specular exponent.
- **Tunnel character**: square / round / Minkowski profiles (variants already exist in the original shader's comments), path frequency, texture choice, fog color.
- **Per-asteroid seed** drives the hash functions so the *same asteroid always produces the same scan* — determinism is a feature for player trust.
- **Camera schedule generator**: server JSON (list of finds) -> phase timeline. Dwell on each find, cruise through empty stretches; an "empty scan" reads visually distinct from a "rich scan" for free — something video can't do.
## Improvement suggestions (from prototype review)
1. **Pass the current deposit as a uniform** instead of recomputing `getDeposit()` (path + hash) inside `map()`, which runs ~128+ times/pixel. Meaningful perf win, and it's what server-driven data requires anyway.
2. **Performance tiers**: render to half/quarter-res framebuffer and upscale (raymarched cave visuals survive upscaling well); reduce raymarch steps and AO samples on low tier.
3. **Diegetic framing**: present as a scanner monitor that doesn't fill the screen — hides perf cost and sells the fiction. Add scanline overlay, HUD elements, depth ticker, slight signal noise (near-zero cost, big believability win).
4. **Variation matrix** to fight repetitiveness: 2-3 tunnel profiles x a few texture/palette sets x path character. Make the cutscene skippable/compressible after the first few viewings.
5. **Generalized camera schedule**: build `cameraZ()` phases from the server's find list rather than a fixed cycle; consider a faster "disappointed pull-through" for empty scans.
6. **Multiple mineral types per scan** (different deposit colors/materials in one flythrough).
7. **Offline capture fallback**: the shader can be rendered offline at any quality and captured to video for marketing/story beats — shader path keeps video as the special case, not the default.
## Risks / caveats
- **Fragment shader is heavy**: 128 raymarch steps; each `map()` is non-trivial; +5 map calls for AO, +10 for curvature, +6 for normals, and 12-24 texture fetches for triplanar bump mapping. Fine on desktop GPU at 1080p; WebGL on integrated/mobile GPUs may drop below 30fps at full screen/full res. Mitigations in suggestions 1-3.
- **Reads as a tunnel, not an asteroid interior**: framing problem, not a rendering problem — bore-view / synthetic-aperture scan fiction solves it (suggestion 3).
- **Repetitiveness** if players scan constantly — needs the variation matrix (suggestion 4) plus skippability.
- **Porting effort**: Shadertoy -> own GL context is mostly boilerplate (fullscreen quad, `iTime`/`iResolution`/`iChannel` -> uniforms), ~1-2 days incl. uniform plumbing and quality tiers. The camera-schedule generator is the only real new code, and it's small.
## Acceptance criteria
- [ ] Shader ported off Shadertoy into the app's GL context (fullscreen quad + uniforms)
- [ ] Deposits (position/size/count/mineral color) driven by server response via uniforms, not hardcoded
- [ ] Camera schedule generated from the find list; slow-mo + pause on each find; distinct empty-scan behavior
- [ ] Deterministic per-asteroid seed (same asteroid = same scan)
- [ ] Quality tiers (resolution scale, step/AO counts) + framed scanner-monitor presentation
- [ ] **Frame-rate spot check on the worst target device at intended viewport size** — validate this FIRST before investing further
- [ ] Skippable after first viewing
## Prototype
Working prototype (hardcoded "server data": one deposit per 20s / 60-unit cycle, endlessly looping):
<details>
<summary>Full prototype shader (GLSL, Shadertoy-ready; iChannel0 = wall rock texture, iChannel1 = floor texture)</summary>
```glsl
#define PI 3.1415926535898
#define FH 1.0 // Floor height. Set it to 2.0 to get rid of the floor.
// ---------------------------------------------------------------------
// GOLD: "Server data" -- hardcoded for now. In production, these become
// uniforms driven by the server response (deposit position, size, type).
// One deposit is found per cycle, endlessly.
#define CYCLE_T 20.0 // Seconds per find-cycle.
#define CYCLE_DIST 60.0 // Distance the probe travels per cycle.
#define DEPOSIT_Z 50.0 // Where within each cycle the deposit sits.
const vec3 GOLD = vec3(1.0, 0.72, 0.25);
// ---------------------------------------------------------------------
// Grey scale.
float getGrey(vec3 p){ return p.x*0.299 + p.y*0.587 + p.z*0.114; }
// Non-standard vec3-to-vec3 hash function.
vec3 hash33(vec3 p){
float n = sin(dot(p, vec3(7, 157, 113)));
return fract(vec3(2097152, 262144, 32768)*n);
}
// 2x2 matrix rotation.
mat2 rot2(float a){
float c = cos(a); float s = sin(a);
return mat2(c, s, -s, c);
}
// Tri-Planar blending function. Based on an old Nvidia tutorial.
vec3 tex3D( sampler2D tex, in vec3 p, in vec3 n ){
n = max((abs(n) - 0.2)*7., 0.001);
n /= (n.x + n.y + n.z );
return (texture(tex, p.yz)*n.x + texture(tex, p.zx)*n.y + texture(tex, p.xy)*n.z).xyz;
}
// Triangle function.
vec3 tri(in vec3 x){return abs(x-floor(x)-.5);}
// The function used to perturb the walls of the cavern.
float surfFunc(in vec3 p){
return dot(tri(p*0.5 + tri(p*0.25).yzx), vec3(0.666));
}
// The path is a 2D sinusoid that varies over time.
vec2 path(in float z){ float s = sin(z/24.)*cos(z/12.); return vec2(s*12., 0.); }
// ---------------------------------------------------------------------
// GOLD: Deposit descriptor for whatever cycle the given Z falls in.
// Returns xyz = deposit center, w = deposit radius. The hash gives each
// find a slightly different position and size -- exactly the values a
// server would supply instead.
vec4 getDeposit(float zRef){
float cy = floor(zRef/CYCLE_DIST);
vec3 h = hash33(vec3(cy + 1.0, cy + 57.0, cy + 113.0));
float dz = cy*CYCLE_DIST + DEPOSIT_Z;
// Sits on the floor, near the tunnel center, roughly nugget-sized.
return vec4(path(dz).x + (h.x - 0.5)*1.5, -FH + 0.3 + h.y*0.15, dz, 0.4 + h.z*0.2);
}
// GOLD: How "golden" a surface point is: a solid core on the nugget
// itself, plus noisy veins bleeding into the surrounding rock.
float goldMask(vec3 p){
vec4 dep = getDeposit(p.z);
float d = length(p - dep.xyz);
float core = smoothstep(dep.w + 0.45, dep.w*0.5, d);
float veins = dot(tri(p*3.0 + tri(p*1.5).yzx), vec3(0.666));
float halo = smoothstep(dep.w + 1.6, dep.w, d)*smoothstep(0.42, 0.18, veins);
return clamp(core + halo*0.85, 0., 1.);
}
// GOLD: Time-warped camera distance. Cruise -> slow-motion approach ->
// pause on the deposit -> accelerate away. Repeats every CYCLE_T seconds.
// "focus" ramps 0..1 as the probe locks onto the find; it drives the
// camera aim and the spotlight.
float cameraZ(in float time, out float focus){
float cyc = floor(time/CYCLE_T);
float t = time - cyc*CYCLE_T;
float z;
if(t < 9.0){
// Cruise at full speed (5 units/sec, same feel as the original).
z = t/9.0*45.0;
focus = smoothstep(4.0, 9.0, t)*0.4; // Deposit "detected" ahead.
}
else if(t < 14.0){
// Slow-motion approach: ease-out so the probe decelerates
// as it comes right up to the deposit.
float u = (t - 9.0)/5.0;
u = 1.0 - (1.0 - u)*(1.0 - u);
z = mix(45.0, 48.6, u);
focus = 0.4 + 0.6*(t - 9.0)/5.0;
}
else if(t < 17.0){
// Paused: barely drifting, spotlighting the find.
z = 48.6 + (t - 14.0)*0.03;
focus = 1.0;
}
else{
// Resume the descent, smoothly back up to speed.
float u = (t - 17.0)/3.0;
z = mix(48.69, 60.0, u*u*(3.0 - 2.0*u));
focus = 1.0 - u;
}
return cyc*CYCLE_DIST + z;
}
// ---------------------------------------------------------------------
// Standard tunnel distance function with perturbation and a floor.
float map(vec3 p){
float sf = surfFunc(p - vec3(0, cos(p.z/3.)*.15, 0));
// Square tunnel.
vec2 tun = abs(p.xy - path(p.z))*vec2(0.5, 0.7071);
float n = 1. - max(tun.x, tun.y) + (0.5 - sf);
n = min(n, p.y + FH);
// GOLD: Blend an actual lumpy nugget into the scene geometry, so
// the deposit physically protrudes from the floor and catches light,
// AO and curvature shading like the rest of the cavern.
vec4 dep = getDeposit(p.z);
float nug = length(p - dep.xyz) - dep.w*(0.8 + sf*0.5);
return min(n, nug);
}
// Texture bump mapping.
vec3 doBumpMap( sampler2D tex, in vec3 p, in vec3 nor, float bumpfactor){
const float eps = 0.001;
float ref = getGrey(tex3D(tex, p , nor));
vec3 grad = vec3( getGrey(tex3D(tex, vec3(p.x - eps, p.y, p.z), nor)) - ref,
getGrey(tex3D(tex, vec3(p.x, p.y - eps, p.z), nor)) - ref,
getGrey(tex3D(tex, vec3(p.x, p.y, p.z - eps), nor)) - ref )/eps;
grad -= nor*dot(nor, grad);
return normalize( nor + grad*bumpfactor );
}
// Surface normal.
vec3 getNormal(in vec3 p) {
const float eps = 0.001;
return normalize(vec3(
map(vec3(p.x + eps, p.y, p.z)) - map(vec3(p.x - eps, p.y, p.z)),
map(vec3(p.x, p.y + eps, p.z)) - map(vec3(p.x, p.y - eps, p.z)),
map(vec3(p.x, p.y, p.z + eps)) - map(vec3(p.x, p.y, p.z - eps))
));
}
// Based on original by IQ.
float calculateAO(vec3 p, vec3 n){
const float AO_SAMPLES = 5.0;
float r = 0.0, w = 1.0, d;
for (float i = 1.0; i<AO_SAMPLES + 1.1; i++){
d = i/AO_SAMPLES;
r += w*(d - map(p + n*d));
w *= 0.5;
}
return 1.0 - clamp(r, 0.0, 1.0);
}
// Cheap curvature, by Shadertoy user Nimitz.
float curve(in vec3 p, in float w){
vec2 e = vec2(-1., 1.)*w;
float t1 = map(p + e.yxx), t2 = map(p + e.xxy);
float t3 = map(p + e.xyx), t4 = map(p + e.yyy);
return 0.125/(w*w) *(t1 + t2 + t3 + t4 - 4.*map(p));
}
void mainImage( out vec4 fragColor, in vec2 fragCoord ){
// Screen coordinates.
vec2 uv = (fragCoord - iResolution.xy*0.5)/iResolution.y;
// GOLD: Camera position comes from the time-warped schedule now.
float focus;
vec3 camPos = vec3(0.0, 0.0, cameraZ(iTime, focus));
vec3 lookAt = camPos + vec3(0.0, 0.1, 0.5); // "Look At" position.
vec3 light_pos = camPos + vec3(0.0, 0.125, -0.125);
vec3 light_pos2 = camPos + vec3(0.0, 0.0, 6.0);
// Sending the camera, "look at," and light vectors down the tunnel.
lookAt.xy += path(lookAt.z);
camPos.xy += path(camPos.z);
light_pos.xy += path(light_pos.z);
light_pos2.xy += path(light_pos2.z);
// GOLD: The upcoming deposit, plus a warm spotlight hovering over it.
// As "focus" ramps up, the camera aims at the find.
vec4 dep = getDeposit(camPos.z);
vec3 light_pos3 = dep.xyz + vec3(0.0, 1.2, -1.5);
lookAt = mix(lookAt, dep.xyz, focus*0.5);
float FOV = PI/3.; // FOV - Field of view.
vec3 forward = normalize(lookAt-camPos);
vec3 right = normalize(vec3(forward.z, 0., -forward.x ));
vec3 up = cross(forward, right);
// rd - Ray direction.
vec3 rd = normalize(forward + FOV*uv.x*right + FOV*uv.y*up);
// Swiveling the camera from left to right when turning corners.
rd.xy = rot2( path(lookAt.z).x/32. )*rd.xy;
// Standard ray marching routine.
float t = 0.0, dt;
for(int i=0; i<128; i++){
dt = map(camPos + rd*t);
if(dt<0.005 || t>150.){ break; }
t += dt*0.75;
}
vec3 sceneCol = vec3(0.);
// The ray has effectively hit the surface, so light it up.
if(dt<0.005){
vec3 sp = t * rd+camPos;
vec3 sn = getNormal(sp);
const float tSize0 = 1./1.;
const float tSize1 = 1./4.;
if (sp.y<-(FH-0.005)) sn = doBumpMap(iChannel1, sp*tSize1, sn, 0.025); // Floor.
else sn = doBumpMap(iChannel0, sp*tSize0, sn, 0.025); // Walls.
float ao = calculateAO(sp, sn);
vec3 ld = light_pos-sp;
vec3 ld2 = light_pos2-sp;
float distlpsp = max(length(ld), 0.001);
float distlpsp2 = max(length(ld2), 0.001);
ld /= distlpsp;
ld2 /= distlpsp2;
float atten = min(1./(distlpsp) + 1./(distlpsp2), 1.);
float ambience = 0.25;
float diff = max( dot(sn, ld), 0.0);
float diff2 = max( dot(sn, ld2), 0.0);
float spec = pow(max( dot( reflect(-ld, sn), -rd ), 0.0 ), 8.);
float spec2 = pow(max( dot( reflect(-ld2, sn), -rd ), 0.0 ), 8.);
float crv = clamp(curve(sp, 0.125)*0.5 + 0.5, .0, 1.);
float fre = pow( clamp(dot(sn, rd) + 1., .0, 1.), 1.);
vec3 texCol;
if (sp.y<-(FH - 0.005)) texCol = tex3D(iChannel1, sp*tSize1, sn); // Floor.
else texCol = tex3D(iChannel0, sp*tSize0, sn); // Walls.
float shading = crv*0.5 + 0.5;
// Glow.
sceneCol = getGrey(texCol)*((diff + diff2)*0.75 + ambience*0.25) +
(spec + spec2)*texCol*2. + fre*crv*texCol.zyx*2.;
// GOLD: Metallic gold response for the deposit and its veins.
// Tighter, hotter speculars so it reads as metal, not rock.
float g = goldMask(sp);
if(g > 0.001){
float gspec = pow(max( dot( reflect(-ld2, sn), -rd ), 0.0 ), 32.);
vec3 goldScene = GOLD*((diff + diff2)*0.85 + ambience*0.3)
+ GOLD*(spec + spec2)*3.0
+ vec3(1.0, 0.9, 0.6)*gspec*4.0;
sceneCol = mix(sceneCol, goldScene, g);
}
// Shading.
sceneCol *= atten*shading*ao;
// Drawing the lines on the walls.
sceneCol *= clamp(1.-abs(curve(sp, 0.0125)), .0, 1.);
// GOLD: Spotlight over the deposit -- fades in with "focus," so the
// find gets lit up as the probe slows and pauses on it.
if(focus > 0.001){
vec3 ld3 = light_pos3 - sp;
float distlpsp3 = max(length(ld3), 0.001);
ld3 /= distlpsp3;
float diff3 = max(dot(sn, ld3), 0.0);
float spec3 = pow(max( dot( reflect(-ld3, sn), -rd ), 0.0 ), 16.);
float atten3 = focus*min(2.0/(distlpsp3*distlpsp3), 1.0);
sceneCol += (diff3*vec3(1.0, 0.85, 0.5)*getGrey(texCol) +
spec3*vec3(1.0, 0.9, 0.6))*atten3*ao;
}
// GOLD: A soft emissive pulse on the gold itself while focused,
// so the deposit visibly "registers" as a find during the pause.
sceneCol += GOLD*g*(0.08 + 0.3*focus*(0.5 + 0.5*sin(iTime*3.0)));
}
fragColor = vec4(clamp(sceneCol, 0., 1.), 1.0);
}
```
</details>
**Prototype timeline knobs:** phases in `cameraZ()` — seconds 0-9 cruise, 9-14 slow-motion approach, 14-17 full pause, 17-20 pull away; `CYCLE_T`/`CYCLE_DIST` retime it. Camera stops ~1.3 units short of the deposit so it fills the view. The `getDeposit()` vec4 (position + radius) is exactly the payload the server would send, plus mineral color.
Audited against origin/master — NOT DONE. No work has started. Untouched since filing on 2026-07-16 (zero comments, updated_at == created_at).
No cutscene or scan implementation: searching the space-game tree (112 files) for asteroid.*scan / scan.*cutscene returns nothing.
No shader assets: git ls-tree for space-game/**/*.{glsl,frag,vert} → zero files.
No pre-rendered video referenced either — grepping the space-game tree for .mp4/.webm returns nothing. Worth flagging, because the ticket is framed as "replaces pre-rendered video": either that video lives outside the component tree (assets folder, or fetched from MinIO), or it was already removed and the scan sequence simply isn't there at all. Either way the premise is worth re-checking before someone plans a replacement for something that may not currently exist.
The only asteroid-related file is space-game/asteroid-generator.ts, which is procedural asteroid geometry — not the scan sequence.
One genuinely useful precedent for whoever picks this up: the codebase already has a working THREE.ShaderMaterial pattern in this exact game — space-game/effects/shield-effect.ts:249 and :285 create shader materials, with per-frame uniform updates at :326 and :400. That's the same shape a procedural scan effect needs (custom material + animated uniforms driven from the render loop), so this isn't greenfield — there's a local convention to copy rather than inventing one.
This is an enhancement-labelled feature request with no dependency on anything else I've audited, so it's schedulable whenever it's wanted. Notes are accurate as written; nothing to correct beyond the video-premise question above.
Audited against `origin/master` — **NOT DONE. No work has started.** Untouched since filing on 2026-07-16 (zero comments, `updated_at` == `created_at`).
- No cutscene or scan implementation: searching the space-game tree (112 files) for `asteroid.*scan` / `scan.*cutscene` returns **nothing**.
- No shader assets: `git ls-tree` for `space-game/**/*.{glsl,frag,vert}` → **zero files**.
- No pre-rendered video referenced either — grepping the space-game tree for `.mp4`/`.webm` returns **nothing**. Worth flagging, because the ticket is framed as *"replaces pre-rendered video"*: either that video lives outside the component tree (assets folder, or fetched from MinIO), or it was already removed and the scan sequence simply isn't there at all. Either way the premise is worth re-checking before someone plans a replacement for something that may not currently exist.
- The only asteroid-related file is `space-game/asteroid-generator.ts`, which is procedural asteroid *geometry* — not the scan sequence.
**One genuinely useful precedent for whoever picks this up:** the codebase already has a working `THREE.ShaderMaterial` pattern in this exact game — `space-game/effects/shield-effect.ts:249` and `:285` create shader materials, with per-frame uniform updates at `:326` and `:400`. That's the same shape a procedural scan effect needs (custom material + animated uniforms driven from the render loop), so this isn't greenfield — there's a local convention to copy rather than inventing one.
This is an `enhancement`-labelled feature request with no dependency on anything else I've audited, so it's schedulable whenever it's wanted. Notes are accurate as written; nothing to correct beyond the video-premise question above.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Use a procedural raymarched tunnel shader (based on the Shadertoy "cavern/tunnel" style shader) as a live, data-driven "scan cutscene" when the player scans an asteroid in the space game. The camera flies down a procedurally generated tunnel (framed as a probe bore-view / penetrating-scan visualization), slows into slow motion and pauses on mineral deposits the server says are inside, then accelerates onward. Replaces the need for pre-rendered cutscene video entirely.
A working prototype shader already exists (see Prototype below): it adds gold "nugget" deposits as real SDF geometry with metallic material + veins, a time-warped camera schedule (cruise -> slow-mo approach -> ~3s pause on the find -> pull away, looping), a focus value that steers the camera at the deposit, and a warm spotlight that fades in over the find.
Why (feasibility assessment)
Video cost: a 10-15s cutscene at 720p+ is ~5-20 MB per clip, fixed resolution, and shows exactly one thing. Reflecting actual scan results (3 gold deposits vs. 1 iron vein vs. nothing) via video needs a combinatorial clip library, or a single generic clip players see through immediately.
Shader cost: a few KB of GLSL + 1-2 small tileable textures (likely already in the asset bundle). Native resolution, no compression artifacts, every parameter live.
Verdict: build it. This is the category of problem procedural rendering exists for. The only genuine risk is fragment-shader cost on low-end devices (see Risks).
How server data drives it
Everything maps cleanly onto uniforms:
getDeposit()), material color, and the camera dwell schedule.vec3swap + maybe a specular exponent.Improvement suggestions (from prototype review)
getDeposit()(path + hash) insidemap(), which runs ~128+ times/pixel. Meaningful perf win, and it's what server-driven data requires anyway.cameraZ()phases from the server's find list rather than a fixed cycle; consider a faster "disappointed pull-through" for empty scans.Risks / caveats
map()is non-trivial; +5 map calls for AO, +10 for curvature, +6 for normals, and 12-24 texture fetches for triplanar bump mapping. Fine on desktop GPU at 1080p; WebGL on integrated/mobile GPUs may drop below 30fps at full screen/full res. Mitigations in suggestions 1-3.iTime/iResolution/iChannel-> uniforms), ~1-2 days incl. uniform plumbing and quality tiers. The camera-schedule generator is the only real new code, and it's small.Acceptance criteria
Prototype
Working prototype (hardcoded "server data": one deposit per 20s / 60-unit cycle, endlessly looping):
Full prototype shader (GLSL, Shadertoy-ready; iChannel0 = wall rock texture, iChannel1 = floor texture)
Prototype timeline knobs: phases in
cameraZ()— seconds 0-9 cruise, 9-14 slow-motion approach, 14-17 full pause, 17-20 pull away;CYCLE_T/CYCLE_DISTretime it. Camera stops ~1.3 units short of the deposit so it fills the view. ThegetDeposit()vec4 (position + radius) is exactly the payload the server would send, plus mineral color.Audited against
origin/master— NOT DONE. No work has started. Untouched since filing on 2026-07-16 (zero comments,updated_at==created_at).asteroid.*scan/scan.*cutscenereturns nothing.git ls-treeforspace-game/**/*.{glsl,frag,vert}→ zero files..mp4/.webmreturns nothing. Worth flagging, because the ticket is framed as "replaces pre-rendered video": either that video lives outside the component tree (assets folder, or fetched from MinIO), or it was already removed and the scan sequence simply isn't there at all. Either way the premise is worth re-checking before someone plans a replacement for something that may not currently exist.space-game/asteroid-generator.ts, which is procedural asteroid geometry — not the scan sequence.One genuinely useful precedent for whoever picks this up: the codebase already has a working
THREE.ShaderMaterialpattern in this exact game —space-game/effects/shield-effect.ts:249and:285create shader materials, with per-frame uniform updates at:326and:400. That's the same shape a procedural scan effect needs (custom material + animated uniforms driven from the render loop), so this isn't greenfield — there's a local convention to copy rather than inventing one.This is an
enhancement-labelled feature request with no dependency on anything else I've audited, so it's schedulable whenever it's wanted. Notes are accurate as written; nothing to correct beyond the video-premise question above.