Texture 3D roofs on the GPU and draw the grid in WebGL

The overlay used to clip and affine-map a code texture onto every
visible roof and punch every roof out of a 2D ground grid each frame
(about 13,600 saves and 6,800 clips per frame on a 1,676-file repo).

The existing code overview canvas already matches the world layout, so
it is uploaded once as a mipmapped texture and sampled by the roof
shader. The overlay now only draws sharper text for the few roofs close
enough to magnify that texture, and the grid is a WebGL line pass. The
vertex shader emits a real clip-space w so the texture is perspective-
correct; screen positions are unchanged. JS and canvas time per 3D
frame fell from 284 ms to 27 ms in the same profile.
This commit is contained in:
Andy Dixon
2026-05-02 14:44:09 +01:00
parent e39af39eca
commit e7627c8877
2 changed files with 134 additions and 58 deletions
+28 -20
View File
@@ -35,7 +35,7 @@ let edgesByFile=new Map();
let sceneStats={definitions:0,totalLines:0,known:0,inferred:0,layers:new Map()};
let spatialGrid=new Map();
let visibleScreenItems=[];
let overviewCanvas=null,overviewContext=null,overviewIndex=0;
let overviewCanvas=null,overviewContext=null,overviewIndex=0,overviewUploaded=-1,overviewUploadedAt=0;
let codeTextureBytes=0;
const codeTextureLru=new Map(),codeTextureBudget=192*1024*1024,overviewScale=3;
let selected=null;
@@ -141,7 +141,7 @@ function computeLayout(){
}
function resetCodeCaches(){
overviewCanvas=document.createElement('canvas');overviewCanvas.width=1000*overviewScale;overviewCanvas.height=680*overviewScale;overviewContext=overviewCanvas.getContext('2d',{alpha:true});overviewIndex=0;codeTextureBytes=0;codeTextureLru.clear();
overviewUploaded=-1;overviewCanvas=document.createElement('canvas');overviewCanvas.width=1000*overviewScale;overviewCanvas.height=680*overviewScale;overviewContext=overviewCanvas.getContext('2d',{alpha:true});overviewIndex=0;codeTextureBytes=0;codeTextureLru.clear();
}
const spatialCell=50;
@@ -432,13 +432,12 @@ function drawOverlay(){
codeTexturesPending=false;
codeTextureDeadline=performance.now()+5;
const hitIds=new Set(searchHits.map(hit=>hit.entityId??hit.id)),occupied=[],visible=visibleLayout(w,h);visibleScreenItems=visible;
if(renderer.mode==='3d')drawGroundGrid(ctx);
let labelCount=0;
if(showCode&&renderer.mode==='2d'){
if(renderer.camera.zoom<=2.75)drawCodeOverview(ctx);
else for(const {item,rect} of visible)drawCodeTexture(ctx,item,rect);
}
if(showCode&&renderer.mode==='3d')draw3dCode(ctx,visible);
if(showCode&&renderer.mode==='3d'){fillCodeOverview();uploadCodeOverview();draw3dCode(ctx,visible);}
if(renderer.mode==='2d'){
const labelDepth=renderer.camera.zoom<1.15?1:renderer.camera.zoom<1.8?2:renderer.camera.zoom<3?3:4;
for(const dir of directoryRects){
@@ -465,18 +464,6 @@ function drawOverlay(){
}
function tileCorners(item){return [[item.x,item.y],[item.x+item.w,item.y],[item.x+item.w,item.y+item.h],[item.x,item.y+item.h]].map(([x,y])=>renderer.project([x,y,item.height||0]));}
function drawGroundGrid(ctx){
if(!layoutItems.length)return;
ctx.save();ctx.globalCompositeOperation='destination-over';ctx.strokeStyle='rgba(150,160,255,.11)';ctx.lineWidth=.75;ctx.beginPath();
for(let x=-200;x<=1200;x+=100){const a=renderer.project([x,-200,0]),b=renderer.project([x,900,0]);ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);}
for(let y=-200;y<=900;y+=100){const a=renderer.project([-200,y,0]),b=renderer.project([1200,y,0]);ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);}
ctx.stroke();
// Mask the grid underneath opaque tile tops so it never crosses source surfaces.
ctx.globalCompositeOperation='destination-out';ctx.fillStyle='#000';
for(const {item} of visibleScreenItems){ctx.beginPath();tileCorners(item).forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();ctx.fill();}
ctx.restore();
}
function drawSelectedConnections(ctx){
if(!selected)return;
const origin=layoutById.get(selected.id);if(!origin)return;
@@ -521,13 +508,25 @@ function paintCodeSurface(ctx,item,width,height){
drawSyntaxLine(ctx,source[index]||'',item,pad,pad+index*lineHeight,width-pad,.92,tokens);
}
}
function drawCodeOverview(ctx){
// Paints files into the overview canvas within the frame budget. The same canvas is the 2D
// zoomed-out code layer and, uploaded to the GPU, the texture on every 3D roof.
function fillCodeOverview(){
if(!overviewCanvas)resetCodeCaches();
while(overviewIndex<layoutItems.length&&performance.now()<codeTextureDeadline){
const item=layoutItems[overviewIndex++],x=Math.round(item.x*overviewScale),y=Math.round(item.y*overviewScale),width=Math.max(1,Math.round(item.w*overviewScale)),height=Math.max(1,Math.round(item.h*overviewScale));
overviewContext.save();overviewContext.translate(x,y);overviewContext.beginPath();overviewContext.rect(0,0,width,height);overviewContext.clip();paintCodeSurface(overviewContext,item,width,height);overviewContext.restore();
}
if(overviewIndex<layoutItems.length)codeTexturesPending=true;
}
function uploadCodeOverview(){
// Re-uploading 3000x2040 pixels is costly, so partial progress is sent at most twice a second.
if(overviewUploaded===overviewIndex)return;
const now=performance.now();
if(overviewIndex<layoutItems.length&&now-overviewUploadedAt<500){codeTexturesPending=true;return;}
renderer.setCodeTexture(overviewCanvas);overviewUploaded=overviewIndex;overviewUploadedAt=now;
}
function drawCodeOverview(ctx){
fillCodeOverview();
const zoom=renderer.camera.zoom;
ctx.save();ctx.imageSmoothingEnabled=true;ctx.globalAlpha=.96;ctx.drawImage(overviewCanvas,viewport.clientWidth/2-renderer.camera.x*zoom,viewport.clientHeight/2-renderer.camera.y*zoom,1000*zoom,680*zoom);ctx.restore();
}
@@ -553,8 +552,14 @@ function buildCodeTexture(item,rect){
rememberCodeTexture(item,texture);item._codeSource=sourceKey;item._codeResolution=target;return texture;
}
function draw3dCode(ctx,visible){
// Draw far faces first; opaque top faces occlude text on faces behind them.
const faces=visible.map(entry=>({...entry,depth:renderer.project([entry.item.x+entry.item.w/2,entry.item.y+entry.item.h/2,entry.item.height]).depth})).sort((a,b)=>b.depth-a.depth);
// Roofs already show the GPU code overview. Only roofs close enough for its resolution to blur
// get a sharp per-file texture here, far faces first so nearer roofs cover them.
const faces=[];
for(const entry of visible){
const {item,rect}=entry;if(rect.w/Math.max(.001,item.w)<overviewScale*1.25)continue; // texture not yet magnified
faces.push({...entry,depth:renderer.project([item.x+item.w/2,item.y+item.h/2,item.height]).depth});
}
faces.sort((a,b)=>b.depth-a.depth);
for(const {item,rect} of faces){
const corners=tileCorners(item);if(corners.some(p=>!Number.isFinite(p.x)||!Number.isFinite(p.y)))continue;
ctx.save();ctx.beginPath();corners.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();ctx.clip();
@@ -586,7 +591,10 @@ function animate(now){
for(const key of Object.keys(cameraAnimation.to))renderer.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased;
if(progress>=1)cameraAnimation=null;dirty=true;
}
if(dirty){renderer.render();drawOverlay();dirty=codeTexturesPending;}
if(dirty){
// 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;
}
const delta=now-lastFrame;lastFrame=now;if(delta<100){frameSamples.push(delta);if(frameSamples.length>45)frameSamples.shift();if(frameSamples.length&&Math.floor(now/500)!==Math.floor((now-delta)/500)){const average=frameSamples.reduce((a,b)=>a+b,0)/frameSamples.length;$('#fps').textContent=`${Math.round(1000/average)} fps · ${(average).toFixed(1)} ms`;}}
requestAnimationFrame(animate);
}
+106 -38
View File
@@ -13,68 +13,104 @@ const VERTICES = new Float32Array([
0,1,0, 0,0,0, 1,0,0, 0,1,0, 1,0,0, 1,1,0,
]);
const VS = `#version 300 es
precision highp float;
layout(location=0) in vec3 aUnit;
layout(location=1) in vec4 aRect;
layout(location=2) in vec4 aColorHeight;
// Shared by the tile and grid programs so both use one camera. The 3D branch returns a real
// clip-space w (the view depth), which keeps textures perspective-correct on roofs while
// producing exactly the same screen positions as project() in JS.
const PROJECT = `
uniform vec2 uCenter;
uniform vec2 uViewport;
uniform float uZoom;
uniform float uMode;
uniform vec2 uOrbit;
uniform float uDistance;
vec4 projectWorld(vec3 world){
if(uMode < .5){
vec2 p = (world.xy - uCenter) * uZoom;
return vec4(p.x * 2.0 / uViewport.x, -p.y * 2.0 / uViewport.y, min(world.z, 1.0) * .001, 1.0);
}
vec3 p = vec3((world.xy - uCenter) / 420.0, world.z / 170.0);
float cy=cos(uOrbit.x), sy=sin(uOrbit.x), cp=cos(uOrbit.y), sp=sin(uOrbit.y);
p = vec3(cy*p.x-sy*p.y, sy*p.x+cy*p.y, p.z);
// Positive pitch places the camera above the ground plane.
p = vec3(p.x, cp*p.y+sp*p.z, -sp*p.y+cp*p.z);
float depth = max(.5, uDistance - p.y);
float aspect = uViewport.x / uViewport.y;
return vec4(p.x/aspect*2.7, (p.z-.35)*2.7, (depth-1.0)/10.0*depth, depth);
}`;
const VS = `#version 300 es
precision highp float;
layout(location=0) in vec3 aUnit;
layout(location=1) in vec4 aRect;
layout(location=2) in vec4 aColorHeight;
${PROJECT}
out vec4 vColor;
out vec3 vUnit;
out float vFace;
out vec2 vWorld;
flat out int vFace;
void main(){
float height = max(1.0, aColorHeight.a);
vec3 world = vec3(aRect.xy + aUnit.xy * aRect.zw, aUnit.z * height);
vColor = vec4(aColorHeight.rgb, 1.0);
vUnit = aUnit;
vFace = float(gl_VertexID / 6);
if(uMode < .5){
vec2 p = (world.xy - uCenter) * uZoom;
gl_Position = vec4(p.x * 2.0 / uViewport.x, -p.y * 2.0 / uViewport.y, aUnit.z * .001, 1.0);
} else {
vec3 p = vec3((world.xy - uCenter) / 420.0, world.z / 170.0);
float cy=cos(uOrbit.x), sy=sin(uOrbit.x), cp=cos(uOrbit.y), sp=sin(uOrbit.y);
p = vec3(cy*p.x-sy*p.y, sy*p.x+cy*p.y, p.z);
// Positive pitch places the camera above the ground plane. The previous
// signs put the eye below it, making every useful orbit an underside view.
p = vec3(p.x, cp*p.y+sp*p.z, -sp*p.y+cp*p.z);
float depth = max(.5, uDistance - p.y);
float aspect = uViewport.x / uViewport.y;
gl_Position = vec4(p.x/(depth*aspect)*2.7, (p.z-.35)/depth*2.7, (depth-1.0)/10.0, 1.0);
}
vWorld = world.xy;
vFace = gl_VertexID / 6;
gl_Position = projectWorld(world);
}`;
const FS = `#version 300 es
precision highp float;
in vec4 vColor;
in vec3 vUnit;
in float vFace;
in vec2 vWorld;
flat in int vFace;
uniform float uMode;
uniform float uAlpha;
uniform sampler2D uCode;
uniform float uCodeOn;
out vec4 outColor;
void main(){
if(uMode < .5 && vFace > .5) discard;
vec2 face = vFace < .5 || vFace > 4.5 ? vUnit.xy : vFace < 2.5 ? vUnit.xz : vUnit.yz;
if(uMode < .5 && vFace > 0) discard;
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 border = smoothstep(0.0, fwidth(edge)*1.35 + .009, edge);
// Tile tops match TILE_TOP in app.js so source drawn over a roof blends in; 2D gets a faint layer tint.
vec3 top = vec3(.055, .067, .094);
vec3 base = uMode < .5 ? mix(top, vColor.rgb, .07) : top;
vec3 stroke = mix(vColor.rgb, vec3(1.0), .12);
if(vFace > .5 && vFace < 4.5){
if(vFace > 0 && vFace < 5){
// Walls: a fixed key light per side and a darker foot make buildings read as solids.
float light = vFace < 1.5 ? .34 : vFace < 2.5 ? .2 : vFace < 3.5 ? .26 : .42;
float light = vFace == 1 ? .34 : vFace == 2 ? .2 : vFace == 3 ? .26 : .42;
base = mix(top, vColor.rgb, light) * (.55 + .45 * vUnit.z);
stroke = mix(stroke, base, .45);
} else if(vFace == 0 && uCodeOn > .5){
// Roofs sample the code overview, which is laid out exactly like the 1000x680 world.
vec4 code = texture(uCode, vWorld / vec2(1000.0, 680.0));
base = mix(base, code.rgb / max(code.a, .001), code.a * .92);
}
outColor = vec4(mix(stroke, base, border), uAlpha);
}`;
const GRID_VS = `#version 300 es
precision highp float;
layout(location=0) in vec2 aPoint;
${PROJECT}
void main(){ gl_Position = projectWorld(vec3(aPoint, 0.0)); }`;
const GRID_FS = `#version 300 es
precision highp float;
out vec4 outColor;
void main(){ outColor = vec4(.59, .63, 1.0, .11); }`;
function program(gl, vertex, fragment) {
const value = gl.createProgram();
gl.attachShader(value, shader(gl, gl.VERTEX_SHADER, vertex));
gl.attachShader(value, shader(gl, gl.FRAGMENT_SHADER, fragment));
gl.linkProgram(value);
if (!gl.getProgramParameter(value, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(value));
return value;
}
function shader(gl, type, source) {
const value = gl.createShader(type); gl.shaderSource(value, source); gl.compileShader(value);
if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(value));
@@ -87,11 +123,17 @@ export class LandscapeRenderer {
const gl = canvas.getContext('webgl2', { antialias: true, alpha: true, depth: true, powerPreference: 'high-performance' });
if (!gl) throw new Error('WebGL2 is required');
this.gl = gl;
this.program = gl.createProgram();
gl.attachShader(this.program, shader(gl, gl.VERTEX_SHADER, VS));
gl.attachShader(this.program, shader(gl, gl.FRAGMENT_SHADER, FS));
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(this.program));
this.program = program(gl, VS, FS);
this.gridProgram = program(gl, GRID_VS, GRID_FS);
const grid = [];
for (let x = -200; x <= 1200; x += 100) grid.push(x, -200, x, 900);
for (let y = -200; y <= 900; y += 100) grid.push(-200, y, 1200, y);
this.gridVao = gl.createVertexArray(); gl.bindVertexArray(this.gridVao);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(grid), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
this.gridCount = grid.length / 2;
this.codeTexture = gl.createTexture(); this.codeOn = false;
this.anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
this.vao = gl.createVertexArray(); gl.bindVertexArray(this.vao);
const vertices = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vertices); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
@@ -123,20 +165,46 @@ export class LandscapeRenderer {
this.dpr=dpr; this.gl.viewport(0,0,w,h);
}
render(alpha=1) {
const gl=this.gl; this.resize();
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.useProgram(this.program); gl.bindVertexArray(this.vao);
this.uniforms||=Object.fromEntries(['uCenter','uViewport','uZoom','uMode','uOrbit','uDistance','uAlpha'].map(n=>[n,gl.getUniformLocation(this.program,n)]));
const u=this.uniforms;
// Uploads the code overview canvas as the roof texture; call again after it changes.
setCodeTexture(source) {
const gl=this.gl; gl.bindTexture(gl.TEXTURE_2D,this.codeTexture);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,true); // mipmaps of straight alpha get dark fringes around glyphs
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,source);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
if(this.anisotropy)gl.texParameterf(gl.TEXTURE_2D,this.anisotropy.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(8,gl.getParameter(this.anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
}
setCameraUniforms(u) {
const gl=this.gl;
gl.uniform2f(u.uCenter,this.camera.x,this.camera.y);
gl.uniform2f(u.uViewport,this.width,this.height);
gl.uniform1f(u.uZoom,this.camera.zoom);
gl.uniform1f(u.uMode,this.mode==='3d'?1:0);
gl.uniform2f(u.uOrbit,this.camera.yaw,this.camera.pitch);
gl.uniform1f(u.uDistance,this.camera.distance);
}
render(alpha=1) {
const gl=this.gl; this.resize();
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);
const names=['uCenter','uViewport','uZoom','uMode','uOrbit','uDistance','uAlpha','uCode','uCodeOn'];
this.uniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.program,n)]));
this.gridUniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.gridProgram,n)]));
if(this.mode==='3d'){
// Ground grid first without writing depth; buildings then cover it where they stand.
gl.useProgram(this.gridProgram); gl.bindVertexArray(this.gridVao); this.setCameraUniforms(this.gridUniforms);
gl.depthMask(false); gl.drawArrays(gl.LINES,0,this.gridCount); gl.depthMask(true);
}
const u=this.uniforms;
gl.useProgram(this.program); gl.bindVertexArray(this.vao); this.setCameraUniforms(u);
gl.uniform1f(u.uAlpha,alpha);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,this.codeTexture); gl.uniform1i(u.uCode,0);
gl.uniform1f(u.uCodeOn,this.codeOn&&this.mode==='3d'?1:0);
gl.drawArraysInstanced(gl.TRIANGLES,0,36,this.count);
}