From e7627c8877c8d42908273fae52848258c5a1bcd9 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sat, 2 May 2026 14:44:09 +0100 Subject: [PATCH] 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. --- web/app.js | 48 +++++++++------- web/renderer.js | 144 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 134 insertions(+), 58 deletions(-) diff --git a/web/app.js b/web/app.js index 44195f2..6b51ec9 100644 --- a/web/app.js +++ b/web/app.js @@ -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({...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)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); } diff --git a/web/renderer.js b/web/renderer.js index 00bf73c..4778b8d 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -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); }