Add blade signs so names read while walking along walls

Flat wall signs vanish at grazing angles. Walls facing a walker now
also carry blade signs projecting perpendicular to the wall, repeated
along long walls and offset from the flat signs. Each blade is two
back-to-back faces; the sign shader discards whichever face would read
mirrored from the viewer's side. Blades shorten to the free space in
front of the wall and are skipped when a neighbouring building is
closer than 1.2 m of sign.
This commit is contained in:
Andy Dixon
2026-06-07 11:51:13 +01:00
parent 931556317d
commit c31b0b3d39
4 changed files with 71 additions and 3 deletions
+12 -2
View File
@@ -1,5 +1,5 @@
import { LandscapeRenderer, KIND } from './renderer.js'; import { LandscapeRenderer, KIND } from './renderer.js';
import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, MOVE } from './city.js'; import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, bladeSigns, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, MOVE } from './city.js';
import { LabelAtlas } from './labels.js'; import { LabelAtlas } from './labels.js';
import { apiFetch, progressEvents } from './api.mjs'; import { apiFetch, progressEvents } from './api.mjs';
@@ -799,7 +799,11 @@ function buildSignQuads(signs,focus,radius,eye,heli,yaw){
const entry=label(b.file.name,isSelected?'selected':'plate');if(!entry)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); 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++; 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++;} if(!heli&&d<SIGN_LIMITS.wallRadius&&walls<SIGN_LIMITS.walls){
for(const quad of wallSigns(b,eye,entry.aspect))add(quad,entry);
for(const quad of bladeSigns(b,eye,entry.aspect,bladeClearance))add(quad,entry);
walls++;
}
} }
// Street names on the northern kerb of nearby blocks. // Street names on the northern kerb of nearby blocks.
for(const block of cityModel.blocks){ for(const block of cityModel.blocks){
@@ -810,6 +814,12 @@ function buildSignQuads(signs,focus,radius,eye,heli,yaw){
} }
} }
// Free distance from a wall point outward at sign height, so blades stop short of neighbours.
function bladeClearance(x,y,[nx,ny]){
const origin=[x+nx*.05,y+ny*.05,4],hit=pickRay(origin,[nx,ny,0],[...cityIndex.near(x,y,5)],5);
return hit?hit.distance:5;
}
// Source code on the wall of the building in the crosshair, once you are close enough to read it. // 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}; let facadeState={entry:null,texture:null};
function updateFacade(){ function updateFacade(){
+31
View File
@@ -306,3 +306,34 @@ export function joystick(dx, dy, radius = 56) {
const scale = Math.min(1, length / radius) / length; const scale = Math.min(1, length / radius) / length;
return { forward: -dy * scale, right: dx * scale }; return { forward: -dy * scale, right: dx * scale };
} }
// A quad's text reads correctly only from the side where (u x v) points away from the viewer
// (the world is left-handed). The sign shader applies the same test to skip mirrored faces.
export function readableFrom(quad, eye) {
const [ux, uy, uz] = quad.u, [vx, vy, vz] = quad.v;
const n = [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx];
return n[0] * (eye[0] - quad.c[0]) + n[1] * (eye[1] - quad.c[1]) + n[2] * (eye[2] - quad.c[2]) < 0;
}
// Blade signs stick out from the wall like shop signs, so names read while walking alongside.
// Each blade is two back-to-back faces; only the one readable from the viewer's side is drawn.
// clearance(x, y, direction) returns the free distance outward, so blades never pierce a neighbour.
export function bladeSigns(b, eye, aspect, clearance, height = .7, z = 4.2) {
const out = [], inset = .15;
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;
const count = Math.max(1, Math.floor(wall.width / 24)), segment = wall.width / count, along = [wall.n[1], -wall.n[0]];
const signZ = Math.max(2.6, Math.min(z, b.height - height));
for (let i = 0; i < count; i++) {
// Blades sit a third of the way into each segment so they don't overlap the flat wall sign.
const offset = (i + .3) * segment - wall.width / 2, x = wall.c[0] + along[0] * offset, y = wall.c[1] + along[1] * offset;
const room = clearance ? clearance(x, y, wall.n) - inset - .5 : Infinity;
const width = Math.min(3.2, aspect * height, room);
if (width < 1.2) continue;
const c = [x + wall.n[0] * (inset + width / 2), y + wall.n[1] * (inset + width / 2), signZ];
const u = [wall.n[0] * width / 2, wall.n[1] * width / 2, 0], v = [0, 0, width / aspect / 2];
out.push({ c, u, v }, { c, u: u.map(value => -value), v });
}
}
return out;
}
+7 -1
View File
@@ -171,9 +171,13 @@ layout(location=2) in vec3 aAxisU;
layout(location=3) in vec3 aAxisV; layout(location=3) in vec3 aAxisV;
layout(location=4) in vec4 aUv; layout(location=4) in vec4 aUv;
uniform mat4 uViewProj; uniform mat4 uViewProj;
uniform vec3 uEye;
out vec2 vUv; out vec2 vUv;
out float vDepth; out float vDepth;
flat out int vMirrored;
void main(){ void main(){
// Text reads correctly only when (u x v) faces away from the viewer; back-to-back blade faces rely on this.
vMirrored = dot(cross(aAxisU, aAxisV), uEye - aCenter) >= 0.0 ? 1 : 0;
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);
@@ -219,10 +223,12 @@ const SIGN_FS = `#version 300 es
precision highp float; precision highp float;
in vec2 vUv; in vec2 vUv;
in float vDepth; in float vDepth;
flat in int vMirrored;
uniform sampler2D uTexture; uniform sampler2D uTexture;
uniform float uFogDensity; uniform float uFogDensity;
out vec4 outColor; out vec4 outColor;
void main(){ void main(){
if(vMirrored == 1) discard;
vec4 color = texture(uTexture, vUv); vec4 color = texture(uTexture, vUv);
if(color.a < .02) discard; if(color.a < .02) discard;
outColor = color * exp(-pow(vDepth * uFogDensity, 2.0)); // premultiplied, so fading toward clear outColor = color * exp(-pow(vDepth * uFogDensity, 2.0)); // premultiplied, so fading toward clear
@@ -389,7 +395,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.uniform1f(u.uFogDensity, this.fogDensity()); gl.uniformMatrix4fv(u.uViewProj, false, this.viewProj); gl.uniform1f(u.uFogDensity, this.fogDensity()); gl.uniform3f(u.uEye, ...this.eye);
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);
} }
+21
View File
@@ -40,3 +40,24 @@ test('facade covers the most squarely facing wall within the building', () => {
assert.ok(Math.abs(width / height - .5) < 1e-9 && quad.c[2] + height / 2 <= building.height); assert.ok(Math.abs(width / height - .5) < 1e-9 && quad.c[2] + height / 2 <= building.height);
assert.deepEqual(flatQuad(1, 2, 3, 4, 2), { c: [1, 2, 3], u: [2, 0, 0], v: [0, -1, 0] }); assert.deepEqual(flatQuad(1, 2, 3, 4, 2), { c: [1, 2, 3], u: [2, 0, 0], v: [0, -1, 0] });
}); });
import { bladeSigns, readableFrom } from '../web/city.js';
test('blade signs project from the wall and exactly one face reads from either side', () => {
const south = { x: 0, y: 0, w: 20, h: 10, height: 30 };
const blades = bladeSigns(south, [5, 14, 1.7], 4);
assert.equal(blades.length, 2, 'one blade (two faces) on the south wall only');
const [a, b] = blades;
assert.ok(Math.abs(a.u[0]) < 1e-9 && a.u[1] > 0 && a.c[1] > 10, 'perpendicular to the wall, outside it');
for (const eye of [[-5, 14, 1.7], [25, 14, 1.7]]) {
assert.equal(readableFrom(a, eye) + readableFrom(b, eye), 1, `one readable face from ${eye}`);
}
assert.ok(readableFrom(flatQuad(0, 0, 0, 4, 1), [0, 0, 50]), 'ground labels read from above');
});
test('blades shrink to the free space and are skipped when a neighbour is too close', () => {
const b = { x: 0, y: 0, w: 20, h: 10, height: 30 };
assert.ok(bladeSigns(b, [5, 30, 1.7], 10, () => 2.5)[0].u[1] * 2 <= 2.5 - .65 + 1e-9);
assert.equal(bladeSigns(b, [5, 30, 1.7], 10, () => 1).length, 0);
assert.equal(bladeSigns({ x: 0, y: 0, w: 100, h: 10, height: 30 }, [50, 30, 1.7], 4).length, 8, 'repeats along long walls');
});