Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ Every change to the app that lands on `main` is published as a GitHub release by

## More

- [Features](docs/features.md): everything in the office, room by room
- [Features](docs/features.md): everything in the office, room by room, with outdoor weather and clear interiors
- [Agents](docs/agents.md): Claude Code, Codex and OpenCode, models and effort, and the office's prompts
- [Configuration](docs/configuration.md): every command-line option, and where the office keeps its data
- [Maps](docs/maps.md): the castle, the space station, and making a map of your own
Expand Down
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Everything in the office, room by room. Back to the [README](../README.md).
- **Drive the supercars.** Walk up to one of the Lambos or Ferraris in the garage (or the one parked out front) and press **E** to get behind the wheel. **W** is the gas, **S** brakes and then reverses, **A** and **D** steer, **Space** brakes and **H** honks. Take it out of the garage, round the lots and along the street either way, and on out of town round the scenic loop (see below). Columns, lamp posts, trees and the other cars stop you, and anyone standing in your way gets knocked aside. Everyone on your floor sees the car go by with you in it (the roof comes off, so they can see who's driving), and hears its engine and horn. Press **E** at a car someone else is driving to ride along beside them. **E** gets you out, and the car stays parked wherever you leave it until the office restarts, when every car goes back to its spot. Like the people you see, the cars are your floor's own: the garage under another floor has its cars where people on that floor left them.
- **The scenic loop.** Both ends of the street run on out of town into a country road, 1.4 km round with the street, that comes back in at the other end. Heading east, it passes Meadowbrook Farm (a red barn, a silo, a windmill, fields of wheat with hay bales and cows in their pasture), turns south into the pines of Whispering Pines and over a creek on a wooden bridge, then west along Summit Pass under a range of snowy mountains, past a lake with a dock, and through the Granite Tunnel under a spur of them. Round the headland it runs north up the coast past Sunset Beach, with palms, umbrellas, a lifeguard tower, a snack shack, a pier out into the sea and sailboats offshore, and past the lighthouse on its rocky point, whose beam sweeps round at night, back into town. Drive it either way. A sign at the start of each stretch says where you are, and so does the bar at the bottom of the screen while you drive. The road's edges keep you on it: run wide on a bend and you slide along the edge rather than off it. Out there the haze thins so you can see the mountains and the sea, and the sun's shadows go with you. Anyone can walk out there too. A chequered line across the street in front of the office times laps: cross it, go all the way round (either way) and cross it again, and the bar shows your lap and your best, which is kept in your browser. The upper floors look out over more of it than the bottom one.
- **A tower that grows with your projects.** Every floor is a storey of the same building, so from outside it's as tall as you have projects: add enough of them and it's a skyscraper, a balcony off every floor, windows lit here and there at night and a cornice round the top. Up on a higher floor the street is that many storeys further down. Only the bottom floor has the EXIT door and the stairs down; the floors above it have wall there, so take the elevator down to the garage to leave.
- **Day and night, and weather.** Outside the windows a whole day goes by every hour: midnight on the hour, sunrise around a quarter past, noon at half past and sunset around a quarter to, and at night the street lamps, the balcony's string lights and the windows across the street come on while the office lights warm up. The weather comes and goes by itself (sun, clouds, rain, thunderstorms, fog, and snow in winter), with raindrops on the glass, wet streets and snow settling outside. Start the office with `--city Berlin` and the sky follows that city's live forecast instead, with its days as long or as short as they are there this time of year. Under **⚙️** → *Building* → *Outside*, anyone can switch the sky to the real time of day instead (*Real time (24 h)*), so it's night in the office when it's night there, and back to *A day every hour*. It's the same for everyone and stays picked across restarts; until someone picks, `--real-time-sky` (or `AGENT_OFFICE_SKY_CLOCK=real`) says which. Indoors the sun lights the room, but shadows fall from the lamps overhead, not from the street's sun. Everyone sees the same sky, and **⚙️** shows what it's doing.
- **Day and night, and weather.** Outside the windows a whole day goes by every hour: midnight on the hour, sunrise around a quarter past, noon at half past and sunset around a quarter to, and at night the street lamps, the balcony's string lights and the windows across the street come on while the office lights warm up. The weather comes and goes by itself (sun, clouds, rain, thunderstorms, fog, and snow in winter), with raindrops on the glass, wet streets and snow settling outside. Start the office with `--city Berlin` and the sky follows that city's live forecast instead, with its days as long or as short as they are there this time of year. Under **⚙️** → *Building* → *Outside*, anyone can switch the sky to the real time of day instead (*Real time (24 h)*), so it's night in the office when it's night there, and back to *A day every hour*. It's the same for everyone and stays picked across restarts; until someone picks, `--real-time-sky` (or `AGENT_OFFICE_SKY_CLOCK=real`) says which. Outdoor fog stays outside the windows, keeping the office and its expanded back office clear. Indoors the sun lights the room, but shadows fall from the lamps overhead, not from the street's sun. Everyone sees the same sky, and **⚙️** shows what it's doing.
- **Maps: the office, a castle, a space station, or your own.** Under **⚙️** → *Map*, anyone can turn the whole building into another place, for everyone on every floor, and back. The **🏰 Castle** is a great hall with a throne of iron blades at the end: you sit on it, the workers at the long tables down either side get up and line up before you when they're done or need you, and the Hand of the King at your left sends a new one running off to a free seat when you speak to him. Workers there dress as peasants and wear out as they toil, growing long grey beards, getting grubby and stooping, fully after 30 minutes of work. The boards hang on the hall's walls with a scribe at each, and the workers, queue, meetings and PRs all work as in the office. Under the hall is a dungeon: a worker sent home there is marched down to a cell by the Kingsguard and kept for good, wasting away until it starves, and then rotting down to its bones (see [The castle](maps.md#the-castle)). The **🚀 Space station** is a deck in orbit with the Earth, the Moon and a ringed planet outside its windows: a bridge with the captain's chair where the throne would be, a First Officer to bring workers aboard, the boards as displays on the hull, and an airlock. A worker sent home there is marched to it by Security, sealed in under the red lights and blown out into space, and you can watch it tumble away from the observation windows either side, where everyone ever ejected is still adrift (see [The space station](maps.md#the-space-station)). A map is plain JSON, so you can move things about or make your own without touching the code: see [Maps](maps.md).
- **Halloween and Christmas.** Under **⚙️** → *Holiday theme*, anyone can dress the whole building up, for everyone on every floor. For Halloween the workers turn into shambling zombies with stitched grins and bandages, your first-person hands become an undead warlock's, bony and clawed with green witch-fire curling round them, everyone dresses as a warlock (a crooked hat, a purple robe with an orange sash and a ragged hem, and pointed boots), and the dog gets bat wings and a witch's hat. The sky goes creepy, purple overhead and blood orange at the horizon, with a big harvest moon, bats crossing it and circling the building, and the odd far-off flash. Jack-o'-lanterns glow everywhere: on every desk, the window sills, the counter, the balcony rail and down the street, with gravestones on the lawn and cobwebs in the corners. For Christmas the workers are elves, your hands are in mittens, everyone wears a Santa suit (the hat, white fur trim, a black belt with a gold buckle, green mittens and black boots), the dog is Rudolph, the potted plants turn into little decorated trees with presents under them, and it snows outside, onto a big lit tree and a few snowmen out front. *By the calendar* (the default) puts up Halloween through October and Christmas through December, by the office's clock.
- **Smoke breaks.** Glass doors on the south wall slide open onto a balcony with string lights, a bench and a bistro table. Press **E** at the ashtray to light up. Everyone sees you puffing away until you stub it out or step back inside.
Expand Down
75 changes: 75 additions & 0 deletions src/client/world/fog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { FLOOR, WALL_HEIGHT } from '../../shared/layout';

/**
* The furthest off the haze ever is, however high up you are: past that nothing's built (the grass
* and the road round the office end there, the city round the roof just past it), so it hides that.
*/
export const HAZE_MAX = 300;
/**
* The haze thins out with height over the street: past HAZE_CLEAR meters up, every HAZE_ABOVE
* meters more you see as far again as down on the street (from the roof of six floors, 3.4 times).
*/
export const HAZE_CLEAR = 6;
export const HAZE_ABOVE = 17.5;

const v3 = (x: number, y: number, z: number) => `vec3(${x.toFixed(3)}, ${y.toFixed(3)}, ${z.toFixed(3)})`;

export const OFFICE_REGION = /* glsl */ `
// Inside the office's walls (and up through its open top), or the back office's, up to its ceiling
// and no further: its roof, and the cornice over where the wall came down, are outdoors.
float skyInOffice( vec3 p ) {
vec3 d = max( ${v3(FLOOR.minX - 0.02, -0.06, FLOOR.minZ - 0.02)} - p, p - ${v3(FLOOR.maxX + 0.02, 40, FLOOR.maxZ + 0.02)} );
vec3 w = max( vec3( skyWing.x, -0.06, skyWing.z ) - p, p - vec3( skyWing.y, ${(WALL_HEIGHT + 0.005).toFixed(3)}, skyWing.w ) );
float wing = length( max( w, 0.0 ) ) + step( ${(WALL_HEIGHT + 0.005).toFixed(3)}, p.y );
return 1.0 - smoothstep( 0.0, 0.12, min( length( max( d, 0.0 ) ), wing ) );
}

`;

/**
* The haze, over three.js's own fog: it thins out with height over the street (see HAZE_ABOVE), as
* thin as it is at your eye or at what you're looking at, whichever is higher. So from high up you
* see further, the street below included, and from down on the street the top of the building is
* as clear as the view from up there. Past HAZE_MAX there's nothing to see, whatever the height.
*/
export const HAZE_PARS_VERTEX = /* glsl */ `
#ifdef USE_FOG
varying vec3 vSkyFogWorld;
#endif
`;

/** World position of the vertex: the view matrix undone (its rotation's transpose), from the camera. */
export const HAZE_VERTEX = /* glsl */ `
#ifdef USE_FOG
vSkyFogWorld = vec3( dot( viewMatrix[ 0 ].xyz, mvPosition.xyz ), dot( viewMatrix[ 1 ].xyz, mvPosition.xyz ), dot( viewMatrix[ 2 ].xyz, mvPosition.xyz ) ) + cameraPosition;
#endif
`;

export const HAZE_PARS = /* glsl */ `
#ifdef USE_FOG
varying vec3 vSkyFogWorld;
uniform float skyStreet;
#ifndef SKY_LIT
uniform float skyInside;
uniform vec4 skyWing;
${OFFICE_REGION}
#endif
#endif
`;

export const HAZE = /* glsl */ `
#ifdef USE_FOG
#ifdef FOG_EXP2
float fogFactor = 1.0 - exp( - fogDensity * fogDensity * vFogDepth * vFogDepth );
#else
// How many times as far off the haze is as down on the street; and past HAZE_MAX, from 45% of
// the way there, as the haze on the roof always went.
float skyReach = 1.0 + max( max( cameraPosition.y, vSkyFogWorld.y ) - skyStreet - ${HAZE_CLEAR.toFixed(1)}, 0.0 ) / ${HAZE_ABOVE.toFixed(1)};
float fogFactor = max( smoothstep( fogNear, fogFar, vFogDepth / skyReach ), smoothstep( ${(HAZE_MAX * 0.45).toFixed(1)}, ${HAZE_MAX.toFixed(1)}, vFogDepth ) );
#endif
// Mask by the surface, so the view outside a window remains foggy from indoors.
fogFactor *= 1.0 - skyInside * skyInOffice( vSkyFogWorld );
gl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );
#endif
`;

71 changes: 6 additions & 65 deletions src/client/world/sky.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as THREE from 'three';
import { FLOOR, SLAB, STREET_Y, WALL_HEIGHT, WALL_T, WING, wingMinZ } from '../../shared/layout';
import { HAZE_MAX, HAZE_CLEAR, HAZE_ABOVE, OFFICE_REGION, HAZE_PARS_VERTEX, HAZE_VERTEX, HAZE_PARS, HAZE } from './fog';
export { HAZE_MAX } from './fog';
import { FLOOR, SLAB, STREET_Y, WALL_T, WING, wingMinZ } from '../../shared/layout';
import type { SkyState, Theme, Weather } from '../../shared/protocol';
import { guessPlace, skyNow, sunPosition } from '../../shared/sun';
import type { NightParts } from './outside';
Expand All @@ -19,18 +21,6 @@ import type { NightParts } from './outside';

const MAX_LAMPS = 24;
const DEG = Math.PI / 180;
/**
* The furthest off the haze ever is, however high up you are: past that nothing's built (the grass
* and the road round the office end there, the city round the roof just past it), so it hides that.
*/
export const HAZE_MAX = 300;
/**
* The haze thins out with height over the street: past HAZE_CLEAR meters up, every HAZE_ABOVE
* meters more you see as far again as down on the street (from the roof of six floors, 3.4 times).
*/
const HAZE_CLEAR = 6;
const HAZE_ABOVE = 17.5;

/**
* How far off something's lost in the haze (with the fog's far edge down on the street at `far`),
* seen from or standing `above` meters over the street, whichever's higher (see HAZE).
Expand Down Expand Up @@ -67,8 +57,6 @@ const uniforms = {
skyWing: { value: new THREE.Vector4(1, 0, 1, 0) },
};

const v3 = (x: number, y: number, z: number) => `vec3(${x.toFixed(3)}, ${y.toFixed(3)}, ${z.toFixed(3)})`;

const PARS = /* glsl */ `
varying vec3 vSkyWorld;
uniform float skyOn;
Expand All @@ -85,14 +73,7 @@ uniform float skySnow;
uniform float skyDrop;
uniform vec4 skyWing;

// Inside the office's walls (and up through its open top), or the back office's, up to its ceiling
// and no further: its roof, and the cornice over where the wall came down, are outdoors.
float skyInOffice( vec3 p ) {
vec3 d = max( ${v3(FLOOR.minX - 0.02, -0.06, FLOOR.minZ - 0.02)} - p, p - ${v3(FLOOR.maxX + 0.02, 40, FLOOR.maxZ + 0.02)} );
vec3 w = max( vec3( skyWing.x, -0.06, skyWing.z ) - p, p - vec3( skyWing.y, ${(WALL_HEIGHT + 0.005).toFixed(3)}, skyWing.w ) );
float wing = length( max( w, 0.0 ) ) + step( ${(WALL_HEIGHT + 0.005).toFixed(3)}, p.y );
return 1.0 - smoothstep( 0.0, 0.12, min( length( max( d, 0.0 ) ), wing ) );
}
${OFFICE_REGION}

// Under the bottom floor: walled at the back and on the west side, open to the street on the south and east.
float skyInGarage( vec3 p ) {
Expand Down Expand Up @@ -145,60 +126,20 @@ const WORLD = /* glsl */ `
}
`;

/**
* The haze, over three.js's own fog: it thins out with height over the street (see HAZE_ABOVE), as
* thin as it is at your eye or at what you're looking at, whichever is higher. So from high up you
* see further, the street below included, and from down on the street the top of the building is
* as clear as the view from up there. Past HAZE_MAX there's nothing to see, whatever the height.
*/
const HAZE_PARS_VERTEX = /* glsl */ `
#ifdef USE_FOG
varying float vSkyFogY;
#endif
`;

/** How high the vertex is: the view matrix undone (its rotation's transpose), from the camera. */
const HAZE_VERTEX = /* glsl */ `
#ifdef USE_FOG
vSkyFogY = dot( viewMatrix[ 1 ].xyz, mvPosition.xyz ) + cameraPosition.y;
#endif
`;

const HAZE_PARS = /* glsl */ `
#ifdef USE_FOG
varying float vSkyFogY;
uniform float skyStreet;
#endif
`;

const HAZE = /* glsl */ `
#ifdef USE_FOG
#ifdef FOG_EXP2
float fogFactor = 1.0 - exp( - fogDensity * fogDensity * vFogDepth * vFogDepth );
#else
// How many times as far off the haze is as down on the street; and past HAZE_MAX, from 45% of
// the way there, as the haze on the roof always went.
float skyReach = 1.0 + max( max( cameraPosition.y, vSkyFogY ) - skyStreet - ${HAZE_CLEAR.toFixed(1)}, 0.0 ) / ${HAZE_ABOVE.toFixed(1)};
float fogFactor = max( smoothstep( fogNear, fogFar, vFogDepth / skyReach ), smoothstep( ${(HAZE_MAX * 0.45).toFixed(1)}, ${HAZE_MAX.toFixed(1)}, vFogDepth ) );
#endif
gl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );
#endif
`;

// Everything with fog gets the haze above; every lit material also gets the lines before that,
// sharing one set of uniforms. Nothing else in the office uses onBeforeCompile, so this is its
// default; unlit ones (glass, signs, outlines) only get the haze.
THREE.Material.prototype.onBeforeCompile = function (shader) {
if (shader.fragmentShader.includes('#include <fog_fragment>')) {
shader.uniforms.skyStreet = uniforms.skyStreet;
Object.assign(shader.uniforms, { skyStreet: uniforms.skyStreet, skyInside: uniforms.skyInside, skyWing: uniforms.skyWing });
shader.vertexShader = shader.vertexShader.replace('#include <fog_pars_vertex>', `#include <fog_pars_vertex>\n${HAZE_PARS_VERTEX}`).replace('#include <fog_vertex>', `#include <fog_vertex>\n${HAZE_VERTEX}`);
shader.fragmentShader = shader.fragmentShader.replace('#include <fog_pars_fragment>', `#include <fog_pars_fragment>\n${HAZE_PARS}`).replace('#include <fog_fragment>', HAZE);
}
if (!shader.fragmentShader.includes('#include <lights_fragment_end>')) return;
Object.assign(shader.uniforms, uniforms);
shader.vertexShader = shader.vertexShader.replace('#include <common>', '#include <common>\nvarying vec3 vSkyWorld;').replace('#include <project_vertex>', `#include <project_vertex>\n${WORLD}`);
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>', `#include <common>\n${PARS}`)
.replace('#include <common>', `#include <common>\n#define SKY_LIT\n${PARS}`)
.replace('#include <lights_fragment_begin>', `${SURFACE}\n#include <lights_fragment_begin>`)
.replace('#include <lights_fragment_end>', `#include <lights_fragment_end>\n${LIGHT}`);
};
Expand Down
27 changes: 27 additions & 0 deletions tests/fog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as THREE from 'three';
import '../src/client/world/sky.js';

// Exercise the actual material hook with Three.js's shaders, including unlit signs and sprites.
for (const [name, Material] of [
['toon', THREE.MeshToonMaterial],
['basic', THREE.MeshBasicMaterial],
['sprite', THREE.SpriteMaterial],
] as const) {
test(`${name} fog uses the surface's world position and shared indoor boundaries`, () => {
const source = THREE.ShaderLib[name];
const shader = { vertexShader: source.vertexShader, fragmentShader: source.fragmentShader, uniforms: {} };
const material = new Material();
material.onBeforeCompile(shader, {} as THREE.WebGLRenderer);
assert.ok(shader.vertexShader.includes('vSkyFogWorld = vec3('));
assert.ok(shader.fragmentShader.includes('fogFactor *= 1.0 - skyInside * skyInOffice( vSkyFogWorld )'));
assert.ok(shader.fragmentShader.includes('uniform vec4 skyWing'));
const uniforms = shader.uniforms as Record<string, { value: unknown }>;
assert.equal(uniforms.skyInside.value, 1);
assert.ok(uniforms.skyWing.value instanceof THREE.Vector4);
const another = { ...source, uniforms: {} };
new Material().onBeforeCompile(another, {} as THREE.WebGLRenderer);
assert.equal((another.uniforms as typeof uniforms).skyWing, uniforms.skyWing);
});
}
2 changes: 1 addition & 1 deletion tests/size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const CEILINGS: Readonly<Record<string, number>> = {
'src/server/dsh.ts': 1148,
'src/server/workers/manager.ts': 1029,
'src/client/features/rooftop/world.ts': 989,
'src/client/world/sky.ts': 966,
'src/client/world/sky.ts': 907,
'src/server/meetings.ts': 764,
'src/client/world/holiday.ts': 702,
'src/client/features/dog/world.ts': 702,
Expand Down