Night city: fog, lit windows, import trails and search beacons

City mode now renders as a night scene: exponential fog into a dark sky
tuned per camera, brighter neon edges, and procedural window grids on
every wall whose lit share grows with the file's definition count.

Selecting a file draws animated screen-space ribbons from each importer
to each imported building, arcing higher with distance: cyan and solid
for known links, amber and dashed for inferred ones. Search hits raise
light columns over their buildings so matches are visible across the
city. Both effects are additive and never occlude. District names hide
when the helicopter is close.
This commit is contained in:
Andy Dixon
2026-05-31 12:10:54 +01:00
parent 4f80ca4744
commit f035ecb4a5
2 changed files with 165 additions and 14 deletions
+32 -5
View File
@@ -123,12 +123,14 @@ function computeCity(){
for(const item of layoutItems){ for(const item of layoutItems){
const building=cityModel.buildings.get(item.id);if(!building)continue; const building=cityModel.buildings.get(item.id);if(!building)continue;
item.city={...building,file:item};cityEntries.push(item.city); item.city={...building,file:item};cityEntries.push(item.city);
instances.push({...building,color:item.color,atlas:[item.x/1000,item.y/680,item.w/1000,item.h/680],kind:KIND.building,id:item.id}); // More definitions light more windows.
const symbols=item.symbolCount??item.symbols?.length??0;
instances.push({...building,color:item.color,atlas:[item.x/1000,item.y/680,item.w/1000,item.h/680],kind:KIND.building,id:item.id,lit:Math.min(.85,.06+Math.sqrt(symbols)/9)});
} }
cityIndex=spatialIndex(cityEntries); cityIndex=spatialIndex(cityEntries);
cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,top:Math.max(10,...[...cityIndex.near(block.x+block.w/2,block.y+block.h/2,Math.hypot(block.w,block.h)/2)].filter(b=>b.x>=block.x&&b.x+b.w<=block.x+block.w&&b.y>=block.y&&b.y+b.h<=block.y+block.h).map(b=>b.height))})); cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,top:Math.max(10,...[...cityIndex.near(block.x+block.w/2,block.y+block.h/2,Math.hypot(block.w,block.h)/2)].filter(b=>b.x>=block.x&&b.x+b.w<=block.x+block.w&&b.y>=block.y&&b.y+b.h<=block.y+block.h).map(b=>b.height))}));
renderer.setCity(instances,{width:cityModel.width,height:cityModel.height}); renderer.setCity(instances,{width:cityModel.width,height:cityModel.height});
buildMinimap();updateCityHud();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null); buildMinimap();updateCityHud();updateTrails();updateBeacons();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null);
} }
// ---- City navigation: helicopter, walk and fly cameras, HUD, crosshair and minimap ---- // ---- City navigation: helicopter, walk and fly cameras, HUD, crosshair and minimap ----
@@ -463,7 +465,7 @@ function selectFile(file,addHistory=true){
if(!file)coverageActiveIndex=-1; if(!file)coverageActiveIndex=-1;
if(file&&addHistory){history=history.filter(item=>item.id!==file.id);history.unshift(file);history=history.slice(0,30);renderHistory();} if(file&&addHistory){history=history.filter(item=>item.id!==file.id);history.unshift(file);history=history.slice(0,30);renderHistory();}
$('#breadcrumbText').textContent=file?`${currentName} › ${file.path}`:currentName; $('#breadcrumbText').textContent=file?`${currentName} › ${file.path}`:currentName;
renderer.setSelected(file?.id||0);renderInspector();if(file)loadFileDetails(file);dirty=true; renderer.setSelected(file?.id||0);updateTrails();renderInspector();if(file)loadFileDetails(file);dirty=true;
} }
function renderInspector(){ function renderInspector(){
@@ -525,7 +527,7 @@ function mountCoverageList(layer){
} }
function renderResults(hits){ function renderResults(hits){
searchHits=hits;$('#resultBadge').textContent=hits.length||'';$('#resultSummary').textContent=hits.length?`${hits.length} matching files and definitions`:'Type in the filter to search files and symbols.'; searchHits=hits;updateBeacons();$('#resultBadge').textContent=hits.length||'';$('#resultSummary').textContent=hits.length?`${hits.length} matching files and definitions`:'Type in the filter to search files and symbols.';
$('#resultList').innerHTML=hits.map(hit=>`<div class="result-row" data-id="${hit.entityId??hit.id}"><i></i><span><strong>${escapeHtml(hit.name)}</strong><small>${escapeHtml(hit.path)}${hit.line?` · ${hit.line}`:''}</small></span></div>`).join(''); $('#resultList').innerHTML=hits.map(hit=>`<div class="result-row" data-id="${hit.entityId??hit.id}"><i></i><span><strong>${escapeHtml(hit.name)}</strong><small>${escapeHtml(hit.path)}${hit.line?` · ${hit.line}`:''}</small></span></div>`).join('');
$('#resultList').querySelectorAll('.result-row').forEach(row=>row.addEventListener('click',()=>{const file=fileById.get(Number(row.dataset.id));if(file){selectFile(file);focusFile(file);} }));dirty=true; $('#resultList').querySelectorAll('.result-row').forEach(row=>row.addEventListener('click',()=>{const file=fileById.get(Number(row.dataset.id));if(file){selectFile(file);focusFile(file);} }));dirty=true;
} }
@@ -621,6 +623,29 @@ function drawCityOverlay(){
if(showCode){fillCodeOverview();uploadCodeOverview();} if(showCode){fillCodeOverview();uploadCodeOverview();}
} }
// Glowing arcs from the selected building to every file it imports or is imported by.
function updateTrails(){
const origin=selected?.id?layoutById.get(selected.id)?.city:null;
if(!origin||!cityModel){renderer.setTrails([]);return;}
const top=b=>[b.x+b.w/2,b.y+b.h/2,b.height],arcs=[],seen=new Set();
for(const edge of edgesByFile.get(selected.id)||[]){
const key=`${edge.from}>${edge.to}`;if(seen.has(key))continue;seen.add(key);
const from=layoutById.get(edge.from)?.city,to=layoutById.get(edge.to)?.city;if(!from||!to||from===to)continue;
const a=top(from),b=top(to),lift=12+Math.hypot(b[0]-a[0],b[1]-a[1])*.35,points=[];
for(let i=0;i<=28;i++){const t=i/28;points.push([a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t+Math.sin(Math.PI*t)*lift]);}
arcs.push({points,kind:edge.confidence==='known'?0:1});
}
renderer.setTrails(arcs);dirty=true;
}
// Search hits become light columns visible across the city.
function updateBeacons(){
if(!cityModel){renderer.setBeacons([]);return;}
const ids=new Set(searchHits.map(hit=>hit.entityId??hit.id)),beams=[];
for(const id of ids){const b=layoutById.get(id)?.city;if(!b)continue;const w=Math.max(3,Math.min(b.w,b.h)*.5);beams.push({x:b.x+b.w/2-w/2,y:b.y+b.h/2-w/2,w,h:w,height:b.height+Math.max(250,cityModel.width*.4),color:[.95,.8,.3]});}
renderer.setBeacons(beams);dirty=true;
}
// ---- In-world text: ground names, roof plates, wall signs and the facade of the targeted file ---- // ---- In-world text: ground names, roof plates, wall signs and the facade of the targeted file ----
const labelAtlas=new LabelAtlas(); const labelAtlas=new LabelAtlas();
let signState={key:'',at:0}; let signState={key:'',at:0};
@@ -649,7 +674,8 @@ function buildSignQuads(signs,focus,radius,eye,heli,yaw){
const label=(text,style)=>{if(signs.full)return null;const entry=labelAtlas.get(text,style);if(!entry)signs.full=true;return entry;}; const label=(text,style)=>{if(signs.full)return null;const entry=labelAtlas.get(text,style);if(!entry)signs.full=true;return entry;};
const add=(quad,uv)=>signs.quads.push({...quad,uv}),right=heading(yaw).right; const add=(quad,uv)=>signs.quads.push({...quad,uv}),right=heading(yaw).right;
// District names float above their tallest tower, turned toward the camera like skyline signs. // District names float above their tallest tower, turned toward the camera like skyline signs.
for(const district of cityDistricts){ // District names are for orientation from a distance; close in they would fill the view.
for(const district of heli&&renderer.city.distance<350?[]:cityDistricts){
const entry=label(district.block.node.name,'plate');if(!entry)continue; const entry=label(district.block.node.name,'plate');if(!entry)continue;
const b=district.block,width=Math.min(Math.max(b.w,b.h)*.5,Math.max(40,district.top*1.2)),height=width/entry.aspect; const b=district.block,width=Math.min(Math.max(b.w,b.h)*.5,Math.max(40,district.top*1.2)),height=width/entry.aspect;
add({c:[b.x+b.w/2,b.y+b.h/2,district.top+height/2+8],u:[right[0]*width/2,right[1]*width/2,0],v:[0,0,height/2]},entry); add({c:[b.x+b.w/2,b.y+b.h/2,district.top+height/2+8],u:[right[0]*width/2,right[1]*width/2,0],v:[0,0,height/2]},entry);
@@ -819,6 +845,7 @@ function animate(now){
for(const key of Object.keys(cameraAnimation.to))cameraAnimation.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased; for(const key of Object.keys(cameraAnimation.to))cameraAnimation.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased;
if(progress>=1)cameraAnimation=null;dirty=true; if(progress>=1)cameraAnimation=null;dirty=true;
} }
if(renderer.animating)dirty=true;
if(dirty){ if(dirty){
// The overlay fills the code overview, so draw it before the GPU pass that samples it. // The overlay fills the code overview, so draw it before the GPU pass that samples it.
renderer.resize();drawOverlay();renderer.codeOn=showCode;renderer.render();dirty=codeTexturesPending; renderer.resize();drawOverlay();renderer.codeOn=showCode;renderer.render();dirty=codeTexturesPending;
+133 -9
View File
@@ -16,7 +16,7 @@ const VERTICES = new Float32Array([
]); ]);
// Instance kinds in the box program. // Instance kinds in the box program.
export const KIND = { building: 0, block: 1, ground: 2 }; export const KIND = { building: 0, block: 1, ground: 2, beacon: 3 };
const FLOATS_PER_INSTANCE = 16; const FLOATS_PER_INSTANCE = 16;
// Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D. // Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D.
@@ -53,8 +53,10 @@ out vec4 vColor;
out vec3 vUnit; out vec3 vUnit;
out vec2 vAtlas; out vec2 vAtlas;
out vec3 vSize; out vec3 vSize;
out float vDepth;
flat out int vFace; flat out int vFace;
flat out ivec2 vKind; flat out ivec2 vKind;
flat out vec2 vLit;
void main(){ void main(){
int kind = int(aMeta.x + .5); int kind = int(aMeta.x + .5);
float height = kind == 0 && uMode < 1.5 ? max(1.0, aColorHeight.a) : aColorHeight.a; float height = kind == 0 && uMode < 1.5 ? max(1.0, aColorHeight.a) : aColorHeight.a;
@@ -65,7 +67,9 @@ void main(){
vSize = vec3(aRect.zw, height); vSize = vec3(aRect.zw, height);
vFace = gl_VertexID / 6; vFace = gl_VertexID / 6;
vKind = ivec2(kind, int(aMeta.w + .5)); vKind = ivec2(kind, int(aMeta.w + .5));
vLit = aMeta.yz;
gl_Position = projectWorld(world); gl_Position = projectWorld(world);
vDepth = gl_Position.w;
}`; }`;
const FS = `#version 300 es const FS = `#version 300 es
@@ -74,14 +78,30 @@ in vec4 vColor;
in vec3 vUnit; in vec3 vUnit;
in vec2 vAtlas; in vec2 vAtlas;
in vec3 vSize; in vec3 vSize;
in float vDepth;
flat in int vFace; flat in int vFace;
flat in ivec2 vKind; flat in ivec2 vKind;
flat in vec2 vLit;
uniform float uMode; uniform float uMode;
uniform float uAlpha; uniform float uAlpha;
uniform sampler2D uCode; uniform sampler2D uCode;
uniform float uCodeOn; uniform float uCodeOn;
uniform float uFogDensity;
uniform vec3 uFogColor;
out vec4 outColor; out vec4 outColor;
float hash(vec3 p){ return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453); }
vec3 fog(vec3 color){
float amount = 1.0 - exp(-pow(vDepth * uFogDensity, 2.0));
return mix(color, uFogColor, amount);
}
void main(){ void main(){
if(vKind.x == 3){
// Search beacon: a light column fading upward, open at the top.
if(vFace == 0 || vFace == 5) discard;
float fade = pow(1.0 - vUnit.z, 1.6);
outColor = vec4(vColor.rgb * fade * .55, 0.0);
return;
}
if(uMode < .5 && vFace > 0) discard; if(uMode < .5 && vFace > 0) discard;
vec2 face = vFace == 0 || vFace == 5 ? vUnit.xy : vFace < 3 ? vUnit.xz : vUnit.yz; vec2 face = vFace == 0 || vFace == 5 ? vUnit.xy : vFace < 3 ? vUnit.xz : vUnit.yz;
float edge = min(min(face.x, 1.0-face.x), min(face.y, 1.0-face.y)); float edge = min(min(face.x, 1.0-face.x), min(face.y, 1.0-face.y));
@@ -91,9 +111,11 @@ void main(){
vec3 base = uMode < .5 ? mix(top, vColor.rgb, .07) : top; vec3 base = uMode < .5 ? mix(top, vColor.rgb, .07) : top;
vec3 stroke = mix(vColor.rgb, vec3(1.0), .12); vec3 stroke = mix(vColor.rgb, vec3(1.0), .12);
if(vKind.x == 2){ if(vKind.x == 2){
outColor = vec4(.03, .036, .052, uAlpha); // asphalt outColor = vec4(fog(vec3(.03, .036, .052)), uAlpha); // asphalt
return; return;
} }
bool city = uMode > 1.5;
if(city) stroke = mix(vColor.rgb, vec3(1.0), .3) * 1.2; // neon edges at night
if(vKind.x == 1){ if(vKind.x == 1){
// Blocks are raised pavements tinted by their dominant layer. // Blocks are raised pavements tinted by their dominant layer.
base = mix(vec3(.075, .085, .11), vColor.rgb, .07); base = mix(vec3(.075, .085, .11), vColor.rgb, .07);
@@ -102,14 +124,26 @@ void main(){
// Walls: a fixed key light per side and a darker foot make buildings read as solids. // Walls: a fixed key light per side and a darker foot make buildings read as solids.
float light = vFace == 1 ? .34 : vFace == 2 ? .2 : vFace == 3 ? .26 : .42; float light = vFace == 1 ? .34 : vFace == 2 ? .2 : vFace == 3 ? .26 : .42;
base = mix(top, vColor.rgb, light) * (.55 + .45 * vUnit.z); base = mix(top, vColor.rgb, light) * (.55 + .45 * vUnit.z);
stroke = mix(stroke, base, .45); stroke = mix(stroke, base, city ? .25 : .45);
if(city){
// Windows: a grid in metres; the lit share follows the file's definition count.
float wide = vFace < 3 ? vSize.x : vSize.y;
vec2 metres = vec2((vFace < 3 ? vUnit.x : vUnit.y) * wide, vUnit.z * vSize.z);
vec2 cell = floor(metres / vec2(3.2, 3.5)), local = fract(metres / vec2(3.2, 3.5));
float pane = step(.22, local.x) * step(local.x, .78) * step(.3, local.y) * step(local.y, .78);
pane *= step(1.0, cell.y) * step(metres.y, vSize.z - 2.0) * step(1.6, metres.x) * step(metres.x, wide - 1.6);
bool lit = hash(vec3(cell, vLit.y + float(vFace) * 17.0)) < vLit.x;
vec3 glass = lit ? mix(vec3(1.0, .82, .52), vColor.rgb, .35) * (.8 + .4 * hash(vec3(cell.yx, vLit.y))) : base * .55;
base = mix(base, glass, pane);
}
} else if(vFace == 0 && uCodeOn > .5){ } else if(vFace == 0 && uCodeOn > .5){
// Roofs sample the code overview canvas at this file's rectangle. // Roofs sample the code overview canvas at this file's rectangle.
vec4 code = texture(uCode, vAtlas); vec4 code = texture(uCode, vAtlas);
base = mix(base, code.rgb / max(code.a, .001), code.a * .92); base = mix(base, code.rgb / max(code.a, .001), code.a * .92);
} }
if(vKind.y == 1) stroke = vec3(1.0, .94, .54); // selected if(vKind.y == 1) stroke = vec3(1.0, .94, .54); // selected
outColor = vec4(mix(stroke, base, border), uAlpha); vec3 color = mix(stroke, base, border);
outColor = vec4(city ? fog(color) : color, uAlpha);
}`; }`;
const GRID_VS = `#version 300 es const GRID_VS = `#version 300 es
@@ -133,23 +167,61 @@ layout(location=3) in vec3 aAxisV;
layout(location=4) in vec4 aUv; layout(location=4) in vec4 aUv;
uniform mat4 uViewProj; uniform mat4 uViewProj;
out vec2 vUv; out vec2 vUv;
out float vDepth;
void main(){ void main(){
vec3 world = aCenter + aAxisU * (aCorner.x * 2.0 - 1.0) + aAxisV * (aCorner.y * 2.0 - 1.0); vec3 world = aCenter + aAxisU * (aCorner.x * 2.0 - 1.0) + aAxisV * (aCorner.y * 2.0 - 1.0);
vUv = aUv.xy + vec2(aCorner.x, 1.0 - aCorner.y) * aUv.zw; vUv = aUv.xy + vec2(aCorner.x, 1.0 - aCorner.y) * aUv.zw;
gl_Position = uViewProj * vec4(world, 1.0); gl_Position = uViewProj * vec4(world, 1.0);
vDepth = gl_Position.w;
}`; }`;
const SIGN_FLOATS = 13;
// Import trails: screen-space ribbons along arcs, with dashes flowing from importer to imported.
const TRAIL_VS = `#version 300 es
precision highp float;
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aOther;
layout(location=2) in vec3 aSideTKind;
uniform mat4 uViewProj;
uniform vec2 uViewport;
out float vT;
flat out int vKind;
void main(){
vec4 a = uViewProj * vec4(aPos, 1.0), b = uViewProj * vec4(aOther, 1.0);
vT = aSideTKind.y; vKind = int(aSideTKind.z + .5);
if(a.w < .2 || b.w < .2){ gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } // behind the camera
vec2 dir = normalize((b.xy / b.w - a.xy / a.w) * uViewport + 1e-6);
vec2 offset = vec2(-dir.y, dir.x) * aSideTKind.x * 2.5 / uViewport;
gl_Position = a + vec4(offset * a.w, 0.0, 0.0);
}`;
const TRAIL_FS = `#version 300 es
precision highp float;
in float vT;
flat in int vKind;
uniform float uTime;
out vec4 outColor;
void main(){
vec3 color = vKind == 0 ? vec3(.21, .83, .76) : vec3(.97, .74, .3);
float dash = fract(vT * 14.0 - uTime * .9);
float glow = .35 + .65 * smoothstep(.0, .25, dash) * (1.0 - smoothstep(.55, .8, dash));
if(vKind == 1 && dash > .6) discard; // inferred links are dashed
outColor = vec4(color * glow, 0.0);
}`;
const TRAIL_FLOATS = 9;
const SIGN_FS = `#version 300 es const SIGN_FS = `#version 300 es
precision highp float; precision highp float;
in vec2 vUv; in vec2 vUv;
in float vDepth;
uniform sampler2D uTexture; uniform sampler2D uTexture;
uniform float uFogDensity;
out vec4 outColor; out vec4 outColor;
void main(){ void main(){
vec4 color = texture(uTexture, vUv); vec4 color = texture(uTexture, vUv);
if(color.a < .02) discard; if(color.a < .02) discard;
outColor = color; outColor = color * exp(-pow(vDepth * uFogDensity, 2.0)); // premultiplied, so fading toward clear
}`; }`;
const SIGN_FLOATS = 13;
function program(gl, vertex, fragment) { function program(gl, vertex, fragment) {
const value = gl.createProgram(); const value = gl.createProgram();
@@ -195,6 +267,12 @@ export class LandscapeRenderer {
this.corners = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW); this.corners = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW);
this.signLayer = this.quadLayer(); this.facadeLayer = this.quadLayer(); this.signLayer = this.quadLayer(); this.facadeLayer = this.quadLayer();
this.atlasTexture = this.texture(); this.facadeTexture = this.texture(); this.atlasGeneration = -1; this.atlasTexture = this.texture(); this.facadeTexture = this.texture(); this.atlasGeneration = -1;
this.trailProgram = program(gl, TRAIL_VS, TRAIL_FS); this.trailUniforms = uniformsOf(gl, this.trailProgram);
this.trailLayer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 };
gl.bindVertexArray(this.trailLayer.vao); gl.bindBuffer(gl.ARRAY_BUFFER, this.trailLayer.buffer);
[[0, 0], [1, 12], [2, 24]].forEach(([index, offset]) => { gl.enableVertexAttribArray(index); gl.vertexAttribPointer(index, 3, gl.FLOAT, false, TRAIL_FLOATS * 4, offset); });
this.beaconLayer = this.boxLayer();
this.time = 0;
this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 }; this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 };
// City camera: 'heli' orbits a ground target; 'walk' and 'fly' look out from an eye position. // City camera: 'heli' orbits a ground target; 'walk' and 'fly' look out from an eye position.
this.city = { view: 'heli', x: 0, y: 0, yaw: -.12, pitch: .8, distance: 500, ex: 0, ey: 0, ez: EYE_HEIGHT, lookYaw: 0, lookPitch: 0 }; this.city = { view: 'heli', x: 0, y: 0, yaw: -.12, pitch: .8, distance: 500, ex: 0, ey: 0, ez: EYE_HEIGHT, lookYaw: 0, lookPitch: 0 };
@@ -260,6 +338,31 @@ export class LandscapeRenderer {
this.facadeLayer.count = 1; this.facadeLayer.count = 1;
} }
// arcs: [{ points: [[x,y,z], ...], kind: 0 known | 1 inferred }] ordered from importer to imported.
setTrails(arcs) {
const vertices = [];
for (const arc of arcs) {
const n = arc.points.length - 1;
for (let i = 0; i < n; i++) {
const a = arc.points[i], b = arc.points[i + 1], ta = i / n, tb = (i + 1) / n;
// Two triangles per segment; the far end looks back at the near end, so its side flips.
vertices.push(...a, ...b, 1, ta, arc.kind, ...a, ...b, -1, ta, arc.kind, ...b, ...a, -1, tb, arc.kind);
vertices.push(...a, ...b, -1, ta, arc.kind, ...b, ...a, 1, tb, arc.kind, ...b, ...a, -1, tb, arc.kind);
}
}
const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, this.trailLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
this.trailLayer.count = vertices.length / TRAIL_FLOATS;
}
// beams: [{ x, y, w, h, height, color }] rising from search hits.
setBeacons(beams) {
const packed = new Float32Array(beams.length * FLOATS_PER_INSTANCE);
beams.forEach((b, i) => packed.set([b.x, b.y, b.w, b.h, ...b.color, b.height, 0, 0, 0, 0, KIND.beacon, 0, 0, 0], i * FLOATS_PER_INSTANCE));
this.upload(this.beaconLayer, packed);
}
get animating() { return this.mode === 'city' && this.trailLayer.count > 0; }
syncAtlas() { syncAtlas() {
const gl = this.gl, atlas = this.atlas; if (!atlas) return; const gl = this.gl, atlas = this.atlas; if (!atlas) return;
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
@@ -280,7 +383,7 @@ export class LandscapeRenderer {
if (!layer.count) return; if (!layer.count) return;
const gl = this.gl, u = this.signUniforms; const gl = this.gl, u = this.signUniforms;
gl.useProgram(this.signProgram); gl.bindVertexArray(layer.vao); gl.useProgram(this.signProgram); gl.bindVertexArray(layer.vao);
gl.uniformMatrix4fv(u.uViewProj, false, this.viewProj); gl.uniformMatrix4fv(u.uViewProj, false, this.viewProj); gl.uniform1f(u.uFogDensity, this.fogDensity());
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(u.uTexture, 0); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(u.uTexture, 0);
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, layer.count); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, layer.count);
} }
@@ -301,7 +404,7 @@ export class LandscapeRenderer {
// City instances: [{x,y,w,h,height,color,atlas:[u,v,du,dv],kind,id}] in metres. // City instances: [{x,y,w,h,height,color,atlas:[u,v,du,dv],kind,id}] in metres.
setCity(instances, bounds) { setCity(instances, bounds) {
const packed = new Float32Array(instances.length * FLOATS_PER_INSTANCE); const packed = new Float32Array(instances.length * FLOATS_PER_INSTANCE);
instances.forEach((b, i) => packed.set([b.x, b.y, b.w, b.h, ...b.color, b.height, ...(b.atlas || [0, 0, 0, 0]), b.kind, 0, b.id || 0, 0], i * FLOATS_PER_INSTANCE)); instances.forEach((b, i) => packed.set([b.x, b.y, b.w, b.h, ...b.color, b.height, ...(b.atlas || [0, 0, 0, 0]), b.kind, b.lit || 0, b.id || 0, 0], i * FLOATS_PER_INSTANCE));
this.cityInstances = instances; this.cityBounds = bounds; this.cityInstances = instances; this.cityBounds = bounds;
this.upload(this.cityLayer, packed); this.upload(this.cityLayer, packed);
} }
@@ -377,9 +480,19 @@ export class LandscapeRenderer {
this.inverseViewProj = null; this.inverseViewProj = null;
} }
// Night fog: thick enough to give depth, thin enough to keep the city legible from the air.
fogDensity() {
if (this.mode !== 'city') return 0;
const c = this.city, size = Math.max(this.cityBounds?.width || 1000, this.cityBounds?.height || 1000);
if (c.view === 'heli') return 1.1 / (c.distance * 2.4 + size * .8);
return 1 / (c.view === 'walk' ? 520 : 700 + c.ez * 5);
}
render(alpha = 1) { render(alpha = 1) {
const gl = this.gl; this.resize(); const gl = this.gl; this.resize();
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); const fogColor = [.027, .031, .05], fog = this.fogDensity();
if (this.mode === 'city') gl.clearColor(...fogColor, 1); else gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
if (this.mode === '3d') { if (this.mode === '3d') {
// Ground grid first without writing depth; buildings then cover it where they stand. // Ground grid first without writing depth; buildings then cover it where they stand.
@@ -391,8 +504,19 @@ export class LandscapeRenderer {
gl.uniform1f(u.uAlpha, alpha); gl.uniform1f(u.uAlpha, alpha);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.codeTexture); gl.uniform1i(u.uCode, 0); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.codeTexture); gl.uniform1i(u.uCode, 0);
gl.uniform1f(u.uCodeOn, this.codeOn && this.mode !== '2d' ? 1 : 0); gl.uniform1f(u.uCodeOn, this.codeOn && this.mode !== '2d' ? 1 : 0);
gl.uniform1f(u.uFogDensity, fog); gl.uniform3f(u.uFogColor, ...fogColor);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, layer.count); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, layer.count);
if (this.mode === 'city') { if (this.mode === 'city') {
// Light effects add colour and never occlude: additive blending without depth writes.
gl.depthMask(false); gl.blendFunc(gl.ONE, gl.ONE);
if (this.beaconLayer.count) { gl.bindVertexArray(this.beaconLayer.vao); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, this.beaconLayer.count); }
if (this.trailLayer.count) {
const t = this.trailUniforms;
gl.useProgram(this.trailProgram); gl.bindVertexArray(this.trailLayer.vao);
gl.uniformMatrix4fv(t.uViewProj, false, this.viewProj); gl.uniform2f(t.uViewport, this.width, this.height); gl.uniform1f(t.uTime, performance.now() / 1000);
gl.drawArrays(gl.TRIANGLES, 0, this.trailLayer.count);
}
gl.depthMask(true); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// Signs sit on or just off surfaces: test depth, don't write it, and pull them forward a touch. // Signs sit on or just off surfaces: test depth, don't write it, and pull them forward a touch.
this.syncAtlas(); this.syncAtlas();
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false);