TEN THOUSAND, ONE DRAW
A city in one draw call.
Ten thousand buildings in a single instanced mesh. Hover to name one, click to raise it. Each change rewrites one or two instances, and the scene draws only when something changes.
Ten thousand, still data.
The buildings are a plain list in the scene description. The skyline for a seed is built once, so a hover swaps one instance by reference and the diff rewrites that one slot. If frames ever run slow, the scene reports it and the Model lowers the render resolution until they recover.
Explore the example source →See how it works.
The source running this example. Expand a file to follow the Model, Messages and view.
model.ts
import { Option, Schema } from 'effect';
/** The city is `side` × `side` buildings: 10,000 in one instanced mesh. */
export const side = 100;
export const buildingCount = side * side;
export const Model = Schema.Struct({
/** Seeds the skyline; "New city" moves to the next seed. */
seed: Schema.Int,
hovered: Schema.Option(Schema.Int),
/** Buildings raised by a click, by index. */
raised: Schema.Array(Schema.Int),
/** The latest performance factor, 0 to 1: the render resolution follows it. */
quality: Schema.Number,
/** The fps average behind the latest quality change, if any. */
fps: Schema.Option(Schema.Number),
});
export type Model = typeof Model.Type;
export const init = () => ({
model: {
seed: 1,
hovered: Option.none(),
raised: [],
quality: 1,
fps: Option.none(),
} satisfies Model,
});
export const buildingKey = (index: number): string => `b${index}`;
/** Reads a building index back from an instance key. */
export const buildingOf = (key: string): Option.Option<number> =>
Option.filter(
Option.liftPredicate(Number.parseInt(key.slice(1), 10), Number.isInteger),
(index) => key.startsWith('b') && index >= 0 && index < buildingCount,
);
update.ts
import { Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';
import { Message } from './message';
import { buildingOf, type Model } from './model';
type AppReturn = Return<Model, Message>;
/** Raises a building, or lowers it again. */
const toggleRaised = (raised: ReadonlyArray<number>, index: number): ReadonlyArray<number> =>
Option.match(
Option.liftPredicate(index, (candidate) => raised.includes(candidate)),
{
onNone: () => [...raised, index],
onSome: () => raised.filter((other) => other !== index),
},
);
const withBuilding = (
instance: string,
model: Model,
apply: (index: number) => Model,
): AppReturn => {
const next = Option.match(buildingOf(instance), { onNone: () => model, onSome: apply });
return { model: next };
};
/** Resolution never drops below half: below that the city turns to mush. */
const qualityFloor = 0.5;
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
HoveredBuilding: ({ instance }) =>
withBuilding(instance, model, (index) =>
modifyFields(model, { hovered: () => Option.some(index) }),
),
LeftCity: () => ({ model: modifyFields(model, { hovered: () => Option.none() }) }),
ClickedBuilding: ({ instance }) =>
withBuilding(instance, model, (index) =>
modifyFields(model, { raised: (raised) => toggleRaised(raised, index) }),
),
ClickedNewCity: () => ({
model: { ...model, seed: model.seed + 1, raised: [], hovered: Option.none() },
}),
ChangedPerformance: ({ fps, factor }) => ({
model: { ...model, quality: Math.max(qualityFloor, factor), fps: Option.some(fps) },
}),
ReceivedSceneLifecycle: () => ({ model }),
});
scene.ts
/**
* Ten thousand buildings in a single instanced mesh. The skyline for a seed is built once
* and reused, so a hover or a click changes one or two instances by reference and the
* diff rewrites just those slots.
*/
import {
box,
directionalLight,
fixed,
hemisphereLight,
type Instance,
instance,
instances,
mesh,
perspectiveCamera,
plane,
renderSettings,
scene,
standard,
} from '@opsydyn/effect-three';
import { randomLcg } from '@opsydyn/foldkit-viz/math/random';
import { Option } from 'effect';
import { paintColour } from '../palette';
import { buildingCount, buildingKey, type Model, side } from './model';
/** A sequential blue ramp, light to dark: taller buildings read darker. */
const ramp = [0xcde2fb, 0x9ec5f4, 0x6da7ec, 0x3987e5, 0x256abf, 0x184f95];
const tallest = 11;
const heightOf = (next: () => number, index: number): number => {
const x = (index % side) / side - 0.5;
const z = Math.floor(index / side) / side - 0.5;
// Downtown in the middle: taller near the centre, with noise everywhere.
const centrality = Math.max(0, 1 - Math.hypot(x, z) * 2.2);
return 0.2 + (tallest - 0.2) * centrality ** 1.6 * (0.35 + next() * 0.65) + next() * 0.3;
};
const building = (index: number, height: number, color: number): Instance =>
instance({
key: buildingKey(index),
position: [(index % side) - side / 2, height / 2, Math.floor(index / side) - side / 2],
scale: [0.8, height, 0.8],
color,
});
const colourFor = (height: number): number =>
ramp[Math.min(ramp.length - 1, Math.floor((height / tallest) * ramp.length))] ?? 0xcde2fb;
type Skyline = {
readonly heights: ReadonlyArray<number>;
readonly buildings: ReadonlyArray<Instance>;
};
const skylineFor = (seed: number): Skyline => {
const next = randomLcg(seed);
const heights = Array.from({ length: buildingCount }, (_, index) => heightOf(next, index));
return {
heights,
buildings: heights.map((height, index) => building(index, height, colourFor(height))),
};
};
// One cached skyline: rebuilding 10,000 instances on every hover would defeat the diff.
const skylines = new Map<number, Skyline>();
const remember = (seed: number): Skyline => {
const built = skylineFor(seed);
skylines.clear();
skylines.set(seed, built);
return built;
};
const skyline = (seed: number): Skyline =>
Option.getOrElse(Option.fromNullishOr(skylines.get(seed)), () => remember(seed));
/** The buildings, with raised ones taller and coral and the hovered one gold. */
const buildingsFor = (model: Model): ReadonlyArray<Instance> => {
const { heights, buildings } = skyline(model.seed);
const changed = new Map<number, Instance>();
for (const index of model.raised) {
changed.set(index, building(index, (heights[index] ?? 1) * 1.8 + 1, paintColour.coral));
}
Option.map(model.hovered, (index) =>
changed.set(
index,
building(index, changed.get(index)?.scale[1] ?? heights[index] ?? 1, paintColour.gold),
),
);
// Nothing changed: hand back the cached list itself, so the diff stops at the reference.
return Option.match(
Option.liftPredicate(changed, (changes) => changes.size > 0),
{
onNone: () => buildings,
onSome: (changes) => buildings.map((original, index) => changes.get(index) ?? original),
},
);
};
export const sceneFor = (model: Model) =>
scene({
camera: perspectiveCamera({ fov: 40, far: 400, position: [62, 50, 62], lookAt: [0, 2, 0] }),
render: renderSettings({ resolution: model.quality }),
children: [
hemisphereLight({ key: 'sky', color: 0xffffff, groundColor: 0x9aa3b5, intensity: 1.2 }),
directionalLight({ key: 'sun', intensity: 2, position: [40, 80, 20] }),
mesh({
key: 'ground',
rotation: fixed([-Math.PI / 2, 0, 0]),
geometry: plane({ width: side + 6, height: side + 6 }),
material: standard({ color: 0xe8e5dd, roughness: 1 }),
}),
instances({
key: 'city',
geometry: box(),
material: standard({ roughness: 0.7 }),
interactive: true,
instances: buildingsFor(model),
}),
],
});
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Option } from 'effect';
import type { Document, HtmlBuilder } from 'foldkit/html';
import { sceneMount } from '../environment';
import { Message } from './message';
import { buildingCount, type Model } from './model';
import { sceneFor } from './scene';
const readout = (model: Model): string =>
Option.match(model.hovered, {
onNone: () => 'Hover a building to name it; click to raise it.',
onSome: (index) => `Building ${index + 1} of ${buildingCount.toLocaleString('en')}.`,
});
const performanceNote = (model: Model): string =>
Option.match(model.fps, {
onNone: () => 'Full resolution: frames have been fast enough so far.',
onSome: (fps) =>
`Measured ${fps} fps: drawing at ${Math.round(model.quality * 100)}% resolution.`,
});
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
title: 'City — effect-three',
body: h.div(
[h.Class('e3-sample')],
[
EffectThreeFoldkit.view(
{
scene: sceneFor(model),
mount: sceneMount,
className: 'e3-stage',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onPointerMove: ({ instance }) => Message.HoveredBuilding({ instance: instance ?? '' }),
onPointerLeave: () => Message.LeftCity(),
onClick: ({ instance }) => Message.ClickedBuilding({ instance: instance ?? '' }),
onPerformanceChange: ({ fps, factor }) => Message.ChangedPerformance({ fps, factor }),
},
h,
),
h.p([h.Class('e3-readout'), h.AriaLive('polite')], [readout(model)]),
h.div(
[h.Class('e3-controls')],
[
h.button(
[h.Class('e3-button'), h.Type('button'), h.OnClick(Message.ClickedNewCity())],
['New city'],
),
],
),
h.p(
[h.Class('e3-note')],
[
`${buildingCount.toLocaleString('en')} buildings in one instanced mesh and one draw call. `,
'A hover rewrites one or two instances, not ten thousand, and the scene draws only when ',
`something changes. ${performanceNote(model)}`,
],
),
],
),
});