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:
+12
-2
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user