Render city text in WebGL: skyline, roof, wall and street signs

City labels were a 2D overlay that showed through buildings at street
level. labels.js rasterises each label once into a shelf-packed atlas
(partial texSubImage uploads, mipmapped for grazing angles), and a new
instanced quad pass draws them depth-tested but not depth-writing.

District names float above each district's tallest tower facing the
camera, file names sit on roofs near the focus, walls facing a walker
carry name signs repeated every 24 m, and street names are painted on
nearby kerbs. Walking up to the building in the crosshair shows its
source on the facing wall. Signs rebuild only when the focus, heading
or selection changes; an overflowing atlas is cleared and refilled with
the nearest labels first. Placement helpers are unit tested.
This commit is contained in:
Andy Dixon
2026-05-30 14:56:36 +01:00
parent 76c846fdce
commit 4f80ca4744
5 changed files with 360 additions and 12 deletions
+75 -12
View File
@@ -1,5 +1,6 @@
import { LandscapeRenderer, KIND } from './renderer.js';
import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, MOVE } from './city.js';
import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, facadeQuad, heading, MOVE } from './city.js';
import { LabelAtlas } from './labels.js';
import { apiFetch, progressEvents } from './api.mjs';
const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||'';
@@ -45,7 +46,7 @@ let searchHits=[];
let expandedCoverageLayer=null;
let coverageActiveIndex=-1;
let cameraAnimation=null;
let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false;
let cityModel=null,cityEntries=[],cityIndex=null,cityFitted=false,cityDistricts=[];
let currentSnapshot=null;
let currentName='';
let currentMetric='lines';
@@ -125,8 +126,9 @@ function computeCity(){
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});
}
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))}));
renderer.setCity(instances,{width:cityModel.width,height:cityModel.height});
buildMinimap();updateCityHud();
buildMinimap();updateCityHud();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null);
}
// ---- City navigation: helicopter, walk and fly cameras, HUD, crosshair and minimap ----
@@ -580,7 +582,7 @@ function drawOverlay(){
resizeOverlay();const ctx=overlayCtx,w=overlay.clientWidth,h=overlay.clientHeight;ctx.clearRect(0,0,w,h);ctx.save();ctx.font='600 11px Inter, system-ui, sans-serif';ctx.textBaseline='top';
codeTexturesPending=false;
codeTextureDeadline=performance.now()+5;
if(renderer.mode==='city'){visibleScreenItems=[];drawCityOverlay(ctx);ctx.restore();return;}
if(renderer.mode==='city'){visibleScreenItems=[];drawCityOverlay();ctx.restore();return;}
const hitIds=new Set(searchHits.map(hit=>hit.entityId??hit.id)),occupied=[],visible=visibleLayout(w,h);visibleScreenItems=visible;
let labelCount=0;
if(showCode&&renderer.mode==='2d'){
@@ -613,16 +615,77 @@ function drawOverlay(){
ctx.restore();
}
function drawCityOverlay(ctx){
function drawCityOverlay(){
if(cityModel){updateCityReadouts();updateCitySigns();if(showCode)updateFacade();else renderer.setFacade(null);}
// The facade gets this frame's text budget before the background roof atlas.
if(showCode){fillCodeOverview();uploadCodeOverview();}
if(!cityModel)return;
updateCityReadouts();
const occupied=[],w=renderer.width,h=renderer.height;let count=0;
if(selected?.id){const b=layoutById.get(selected.id)?.city;if(b){const p=renderer.project([b.x+b.w/2,b.y+b.h/2,b.height+2]);if(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h)drawChip(ctx,selected.name,p.x,p.y-22,220,'#fff08a',occupied,true,'center');}}
}
// ---- In-world text: ground names, roof plates, wall signs and the facade of the targeted file ----
const labelAtlas=new LabelAtlas();
let signState={key:'',at:0};
const SIGN_LIMITS={roofs:260,walls:120,roofRadius:260,wallRadius:70};
function updateCitySigns(){
const c=renderer.city,{eye}=renderer.cityPose(),heli=c.view==='heli';
const focus=heli?[c.x,c.y]:[c.ex,c.ey],radius=heli?Math.max(SIGN_LIMITS.roofRadius,c.distance*1.3):SIGN_LIMITS.roofRadius;
// Rebuild when the focus moves a few metres, the view or selection changes, or the atlas was reset.
const yaw=heli?c.yaw:c.lookYaw;
const key=[Math.round(focus[0]/6),Math.round(focus[1]/6),Math.round(radius/40),c.view,selected?.id||0,labelAtlas.generation,Math.round(eye[2]/20),Math.round(yaw*10)].join();
if(key===signState.key)return;
signState.key=key;
const signs={quads:[],full:false};
buildSignQuads(signs,focus,radius,eye,heli,yaw);
if(signs.full){
// Start a fresh atlas holding only what is needed here; anything that still does not fit is skipped.
labelAtlas.clear();signs.quads=[];signs.full=false;buildSignQuads(signs,focus,radius,eye,heli,yaw);
signState.key=[...key.split(',').slice(0,5),labelAtlas.generation,...key.split(',').slice(6)].join();
}
renderer.setSigns(signs.quads,labelAtlas);
}
// Nearest labels first, so if the atlas fills up only the most distant ones are lost.
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 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.
for(const district of cityDistricts){
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;
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);
}
const near=[...cityIndex.near(focus[0],focus[1],radius)].map(b=>({b,d:Math.hypot(b.x+b.w/2-focus[0],b.y+b.h/2-focus[1])})).filter(n=>n.d<=radius).sort((a,b)=>a.d-b.d);
const selectedCity=selected?.id?layoutById.get(selected.id)?.city:null;
if(selectedCity&&!near.some(n=>n.b===selectedCity))near.unshift({b:selectedCity,d:0});
let roofs=0,walls=0;
for(const {b,d} of near){
const isSelected=b===selectedCity;
if(roofs>=SIGN_LIMITS.roofs&&!isSelected&&(heli||d>=SIGN_LIMITS.wallRadius))break;
const entry=label(b.file.name,isSelected?'selected':'plate');if(!entry)break;
const height=Math.max(1.2,Math.min(Math.min(b.w,b.h)*.22,heli?12:5)),width=Math.min(b.w*.92,height*entry.aspect);
add(flatQuad(b.x+b.w/2,b.y+b.h/2,b.height+.08,width,width/entry.aspect),entry);roofs++;
if(!heli&&d<SIGN_LIMITS.wallRadius&&walls<SIGN_LIMITS.walls){for(const quad of wallSigns(b,eye,entry.aspect))add(quad,entry);walls++;}
}
// Street names on the northern kerb of nearby blocks.
for(const block of cityModel.blocks){
if(block.depth!==1||count>=40)continue;
const p=renderer.project([block.x+block.w/2,block.y+block.h/2,0]);if(!(p.x>=0&&p.x<=w&&p.y>=0&&p.y<=h))continue;
if(drawChip(ctx,`${block.node.name}/`,p.x,p.y-8,180,'#8d7dff',occupied,false,'center'))count++;
if(block.depth<2||block.depth>3||Math.hypot(block.x+block.w/2-focus[0],block.y+block.h/2-focus[1])>radius)continue;
const entry=label(block.node.name,'ground');if(!entry)break;
const height=Math.min(block.depth===2?2.2:1.4,block.h*.12),width=Math.min(block.w*.7,height*entry.aspect);
add(flatQuad(block.x+block.w/2,block.y+height*.8,.4,width,width/entry.aspect),entry);
}
}
// Source code on the wall of the building in the crosshair, once you are close enough to read it.
let facadeState={entry:null,texture:null};
function updateFacade(){
const c=renderer.city,entry=c.view!=='heli'?cityTargetEntry:null;
const eye=[c.ex,c.ey,c.ez],close=entry&&Math.hypot(entry.x+entry.w/2-eye[0],entry.y+entry.h/2-eye[1])<Math.max(45,Math.max(entry.w,entry.h));
if(!close){if(facadeState.entry){facadeState={entry:null,texture:null};renderer.setFacade(null);}return;}
const item=entry.file,texture=buildCodeTexture(item,{x:0,y:0,w:1024,h:1024*item.h/Math.max(.001,item.w)});
if(!texture)return;
const quad=facadeQuad(entry,eye,texture.width/texture.height);
if(entry!==facadeState.entry||texture!==facadeState.texture||facadeState.quadKey!==JSON.stringify(quad?.c)){
facadeState={entry,texture,quadKey:JSON.stringify(quad?.c)};renderer.setFacade(texture,quad);
}
}
+53
View File
@@ -208,3 +208,56 @@ export function groundHit([ox, oy, oz], [dx, dy, dz]) {
const t = -oz / dz;
return [ox + dx * t, oy + dy * t];
}
// ---- Sign placement. A quad is { c: centre, u: half-width axis, v: half-height axis } in metres. ----
// Text lying flat, reading left to right along +x with its top toward north (-y).
export function flatQuad(cx, cy, z, width, height) {
return { c: [cx, cy, z], u: [width / 2, 0, 0], v: [0, -height / 2, 0] };
}
// The four walls of a footprint: centre on the ground, outward normal, width.
function walls(b) {
return [
{ c: [b.x + b.w / 2, b.y + b.h], n: [0, 1], width: b.w },
{ c: [b.x + b.w / 2, b.y], n: [0, -1], width: b.w },
{ c: [b.x + b.w, b.y + b.h / 2], n: [1, 0], width: b.h },
{ c: [b.x, b.y + b.h / 2], n: [-1, 0], width: b.h },
];
}
// Vertical quad just outside a wall, reading left to right for someone facing that wall.
function wallQuad(wall, z, width, height) {
const offset = .06, right = [wall.n[1], -wall.n[0]];
return { c: [wall.c[0] + wall.n[0] * offset, wall.c[1] + wall.n[1] * offset, z], u: [right[0] * width / 2, right[1] * width / 2, 0], v: [0, 0, height / 2] };
}
// Name signs at first-floor height on the walls that face the viewer.
export function wallSigns(b, eye, aspect, height = .9, z = 3.2) {
const out = [];
for (const wall of walls(b)) {
if ((eye[0] - wall.c[0]) * wall.n[0] + (eye[1] - wall.c[1]) * wall.n[1] <= 0) continue;
// Long walls repeat the sign every ~24 m so one is always near someone walking past.
const count = Math.max(1, Math.floor(wall.width / 24)), segment = wall.width / count, along = [wall.n[1], -wall.n[0]];
const width = Math.min(segment * .85, aspect * height);
for (let i = 0; i < count; i++) {
const offset = (i + .5) * segment - wall.width / 2;
out.push(wallQuad({ ...wall, c: [wall.c[0] + along[0] * offset, wall.c[1] + along[1] * offset] }, Math.min(z, b.height * .5), width, width / aspect));
}
}
return out;
}
// The wall most squarely facing the viewer, covered with the file's source at the texture's aspect.
export function facadeQuad(b, eye, aspect) {
let best = null, score = 0;
for (const wall of walls(b)) {
const dx = eye[0] - wall.c[0], dy = eye[1] - wall.c[1], facing = (dx * wall.n[0] + dy * wall.n[1]) / (Math.hypot(dx, dy) || 1);
if (facing > score) { score = facing; best = wall; }
}
if (!best) return null;
let width = best.width * .9, height = width / aspect;
const room = Math.max(1, (b.height - 1.5) * .92);
if (height > room) { height = room; width = height * aspect; }
return wallQuad(best, 1.2 + height / 2, width, height);
}
+77
View File
@@ -0,0 +1,77 @@
// Text for the 3D city: labels are rasterised once into a shared atlas and drawn as textured quads,
// so signs are depth-tested against buildings instead of floating over them like DOM or 2D overlays.
// Rows of fixed height filled left to right. add() returns a slot or null when the atlas is full.
export class ShelfPacker {
constructor(width, height, rowHeight) { Object.assign(this, { width, height, rowHeight }); this.reset(); }
reset() { this.x = 0; this.y = 0; }
add(width) {
width = Math.min(width, this.width);
if (this.x + width > this.width) { this.x = 0; this.y += this.rowHeight; }
if (this.y + this.rowHeight > this.height) return null;
const slot = { x: this.x, y: this.y, w: width, h: this.rowHeight };
this.x += width + 2;
return slot;
}
}
const STYLES = {
// Name plates on roofs and walls.
plate: { font: '600 30px Inter, system-ui, sans-serif', text: '#f1f3f8', fill: 'rgba(10,12,18,.9)', border: 'rgba(255,255,255,.14)' },
selected: { font: '700 30px Inter, system-ui, sans-serif', text: '#1a1405', fill: 'rgba(255,236,140,.96)', border: 'rgba(255,255,255,.5)' },
// District and street names painted on the ground.
ground: { font: '700 34px Inter, system-ui, sans-serif', text: 'rgba(205,198,255,.9)' },
};
export class LabelAtlas {
constructor(width = 2048, height = 4096, rowHeight = 48) {
this.canvas = document.createElement('canvas');
this.canvas.width = width; this.canvas.height = height;
this.ctx = this.canvas.getContext('2d');
this.packer = new ShelfPacker(width, height, rowHeight);
this.entries = new Map();
this.generation = 0; // bumps when the atlas is cleared and every entry is invalid
this.dirty = null;
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.entries.clear(); this.packer.reset(); this.generation++;
this.dirty = { x0: 0, y0: 0, x1: this.canvas.width, y1: this.canvas.height };
}
// Atlas rectangle for text in a style: { u, v, du, dv, aspect }, or null if it cannot fit.
get(text, style = 'plate') {
const key = `${style}\n${text}`;
const cached = this.entries.get(key);
if (cached) return cached;
const s = STYLES[style], ctx = this.ctx, h = this.packer.rowHeight;
ctx.font = s.font;
const pad = s.fill ? 14 : 4, width = Math.ceil(Math.min(ctx.measureText(text).width + pad * 2, 900));
const slot = this.packer.add(width);
if (!slot) return null;
ctx.save();
ctx.beginPath(); ctx.rect(slot.x, slot.y, slot.w, slot.h); ctx.clip();
if (s.fill) {
ctx.fillStyle = s.fill; ctx.strokeStyle = s.border; ctx.lineWidth = 2;
ctx.beginPath(); ctx.roundRect(slot.x + 1, slot.y + 3, slot.w - 2, h - 6, 12); ctx.fill(); ctx.stroke();
}
ctx.fillStyle = s.text; ctx.textBaseline = 'middle';
ctx.fillText(text, slot.x + pad, slot.y + h / 2 + 1, slot.w - pad * 2);
ctx.restore();
const W = this.canvas.width, H = this.canvas.height;
const entry = { u: slot.x / W, v: slot.y / H, du: slot.w / W, dv: slot.h / H, aspect: slot.w / slot.h };
this.entries.set(key, entry);
const d = this.dirty ||= { x0: slot.x, y0: slot.y, x1: slot.x, y1: slot.y };
d.x0 = Math.min(d.x0, slot.x); d.y0 = Math.min(d.y0, slot.y); d.x1 = Math.max(d.x1, slot.x + slot.w); d.y1 = Math.max(d.y1, slot.y + slot.h);
return entry;
}
// Pixels changed since the last call, as { x, y, image }, or null.
takeDirty() {
const d = this.dirty; if (!d) return null;
this.dirty = null;
const w = Math.max(1, d.x1 - d.x0), h = Math.max(1, d.y1 - d.y0);
return { x: d.x0, y: d.y0, image: this.ctx.getImageData(d.x0, d.y0, w, h) };
}
}
+113
View File
@@ -123,6 +123,34 @@ precision highp float;
out vec4 outColor;
void main(){ outColor = vec4(.59, .63, 1.0, .11); }`;
// Textured quads for city signs and facades: centre, half-width axis, half-height axis, atlas rect.
const SIGN_VS = `#version 300 es
precision highp float;
layout(location=0) in vec2 aCorner;
layout(location=1) in vec3 aCenter;
layout(location=2) in vec3 aAxisU;
layout(location=3) in vec3 aAxisV;
layout(location=4) in vec4 aUv;
uniform mat4 uViewProj;
out vec2 vUv;
void main(){
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;
gl_Position = uViewProj * vec4(world, 1.0);
}`;
const SIGN_FS = `#version 300 es
precision highp float;
in vec2 vUv;
uniform sampler2D uTexture;
out vec4 outColor;
void main(){
vec4 color = texture(uTexture, vUv);
if(color.a < .02) discard;
outColor = color;
}`;
const SIGN_FLOATS = 13;
function program(gl, vertex, fragment) {
const value = gl.createProgram();
gl.attachShader(value, shader(gl, gl.VERTEX_SHADER, vertex));
@@ -163,6 +191,10 @@ export class LandscapeRenderer {
this.anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
this.cube = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this.cube); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
this.landscape = this.boxLayer(); this.cityLayer = this.boxLayer();
this.signProgram = program(gl, SIGN_VS, SIGN_FS); this.signUniforms = uniformsOf(gl, this.signProgram);
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.atlasTexture = this.texture(); this.facadeTexture = this.texture(); this.atlasGeneration = -1;
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.
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 };
@@ -181,6 +213,78 @@ export class LandscapeRenderer {
return layer;
}
quadLayer() {
const gl = this.gl, layer = { vao: gl.createVertexArray(), buffer: gl.createBuffer(), count: 0 };
gl.bindVertexArray(layer.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.corners); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer);
[[1, 3, 0], [2, 3, 12], [3, 3, 24], [4, 4, 36]].forEach(([index, size, offset]) => {
gl.enableVertexAttribArray(index); gl.vertexAttribPointer(index, size, gl.FLOAT, false, SIGN_FLOATS * 4, offset); gl.vertexAttribDivisor(index, 1);
});
return layer;
}
texture() {
const gl = this.gl, texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture);
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);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return texture;
}
// quads: [{ c, u, v, uv: {u, v, du, dv} }] from city.js placement helpers.
static packQuads(quads) {
const packed = new Float32Array(quads.length * SIGN_FLOATS);
quads.forEach((q, i) => packed.set([...q.c, ...q.u, ...q.v, q.uv.u, q.uv.v, q.uv.du, q.uv.dv], i * SIGN_FLOATS));
return packed;
}
setSigns(quads, atlas) {
this.atlas = atlas;
const gl = this.gl, packed = LandscapeRenderer.packQuads(quads);
gl.bindBuffer(gl.ARRAY_BUFFER, this.signLayer.buffer); gl.bufferData(gl.ARRAY_BUFFER, packed, gl.DYNAMIC_DRAW);
this.signLayer.count = quads.length;
}
// Source code on one wall: a canvas and its quad, or null to hide.
setFacade(canvas, quad) {
const gl = this.gl;
if (!canvas || !quad) { this.facadeLayer.count = 0; return; }
if (canvas !== this.facadeSource) {
gl.bindTexture(gl.TEXTURE_2D, this.facadeTexture); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
this.facadeSource = canvas;
}
gl.bindBuffer(gl.ARRAY_BUFFER, this.facadeLayer.buffer);
gl.bufferData(gl.ARRAY_BUFFER, LandscapeRenderer.packQuads([{ ...quad, uv: { u: 0, v: 0, du: 1, dv: 1 } }]), gl.DYNAMIC_DRAW);
this.facadeLayer.count = 1;
}
syncAtlas() {
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);
if (this.atlasGeneration === -1) { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.canvas); this.atlasGeneration = 0; atlas.takeDirty(); this.mipmapAtlas(); return; }
const dirty = atlas.takeDirty();
if (dirty) { gl.texSubImage2D(gl.TEXTURE_2D, 0, dirty.x, dirty.y, gl.RGBA, gl.UNSIGNED_BYTE, dirty.image); this.mipmapAtlas(); }
}
// Signs are mostly seen small and at grazing angles; without mipmaps the text shimmers.
mipmapAtlas() {
const gl = this.gl;
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
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)));
}
drawQuads(layer, texture) {
if (!layer.count) return;
const gl = this.gl, u = this.signUniforms;
gl.useProgram(this.signProgram); gl.bindVertexArray(layer.vao);
gl.uniformMatrix4fv(u.uViewProj, false, this.viewProj);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(u.uTexture, 0);
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, layer.count);
}
upload(layer, packed) {
const gl = this.gl; gl.bindBuffer(gl.ARRAY_BUFFER, layer.buffer); gl.bufferData(gl.ARRAY_BUFFER, packed, gl.DYNAMIC_DRAW);
layer.count = packed.length / FLOATS_PER_INSTANCE; layer.packed = packed;
@@ -288,6 +392,15 @@ export class LandscapeRenderer {
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.drawArraysInstanced(gl.TRIANGLES, 0, 36, layer.count);
if (this.mode === 'city') {
// Signs sit on or just off surfaces: test depth, don't write it, and pull them forward a touch.
this.syncAtlas();
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false);
gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(-2, -8);
this.drawQuads(this.facadeLayer, this.facadeTexture);
this.drawQuads(this.signLayer, this.atlasTexture);
gl.disable(gl.POLYGON_OFFSET_FILL); gl.depthMask(true); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
}
// Geometry of an item in the active world: map units for 2D/3D, metres in the city.