← Selected Work

Garden Sun Simulation

Scrub the time of day, or aggregate a season into a sun-hours heatmap, to find the sunniest spots in a real garden before you plant.

TypeScriptThree.jsSunCalcWeb WorkersVite

Problem

Before you plant anything, you want to know where the sun actually falls. That's hard to eyeball — it shifts hour to hour, month to month, as buildings, fences, and trees throw shadows that move. So: lay out a real garden as a grid of tiles, drop in whatever blocks light, then see the sun. Scrub the time of day and watch shadows sweep across the plot, or aggregate a whole season into a sun-hours heatmap that shows the sunniest and shadiest spots at a glance.

The real engineering problem was underneath: keep a real-time 3D sim responsive while it churns through a genuinely heavy amount of solar math.

Scrubbing the time of day — the sun follows its real arc and shadows track with it

Approach

A pure simulation core behind ports, swappable adapters at the edges. The core — garden model, tiles, shadow-and-sun-hours math — has no DOM or engine dependency, testable headlessly in plain Vitest. Adapters plug into the ports: a Three.js orthographic renderer for the isometric view, a SunCalc provider for real solar positions at a given latitude and date, a Web Worker for the expensive seasonal aggregation. Domain language and the decisions behind the seams live in the repo's CONTEXT.md and docs/adr.

  • Two views, one model. An instantaneous mode (scrub time, watch one moment) and an aggregated mode (sum a season into a heatmap) both read from the same core — the renderer just draws whatever the core computes.
  • Heavy math, off the main thread. Aggregating a season across thousands of tiles is the bottleneck. It runs in a Web Worker and never blocks the interaction.

Result

It holds 60fps at the design ceiling — a ~100×100, 10,000-tile garden — scrubbing time or computing a season's heatmap. Getting there meant clearing two bottlenecks: moving the seasonal aggregation into the Web Worker, and switching the tile grid from one mesh per tile to a single InstancedMesh.

The ports-and-adapters split paid off twice: the solar math verifies headlessly, no renderer needed, and the whole performance story — prior state, diagrams, the actual fixes — is documented, because the bottlenecks lived in identifiable, swappable pieces instead of smeared across the render loop.

The in-app performance HUD, used to verify the 60fps ceiling under stress