Compare commits

...

42 commits

Author SHA1 Message Date
cd4f10bc80
Add ant behavior overhaul implementation plan
7 phases: config changes, world seeding, ant physics (gravity/surface/collision),
priority stack brain, unified brush with ant spawning, sand color variation,
and cleanup. Future phases B (digging pheromone) and C (colony dynamics) captured
as hooks.
2026-03-11 20:35:11 -04:00
fe537218a3
Add ant behavior overhaul design and nest-building research
Design covers ant gravity/physics, unified brush tool, behavioral
priority stack, sand color variation, and config changes. Research
doc captures real ant nest construction patterns for informing
simulation behavior.
2026-03-11 20:31:06 -04:00
2269f26f9f
Update CLAUDE.md with sand physics architecture 2026-03-11 18:12:14 -04:00
685a382d4d
Add material palette to GUI panel 2026-03-11 18:12:14 -04:00
3e13b2ecd2
Add camera view mode toggle between side and top-down 2026-03-11 17:45:47 -04:00
9f958089c0
Add stats overlay with cursor position, TPS, and colony info 2026-03-11 17:45:47 -04:00
d91b5a9b5e
Block pheromone diffusion through solid materials
Checks each blur neighbor's material behavior before including it in
the average. Solid and non-gas cells are excluded so pheromones don't
bleed through walls, sand, or rock.
2026-03-11 16:36:22 -04:00
6b514f338c
Add gravity-aware digging direction for ants
In side-view mode, ants carrying non-food powder material now bias their
angle upward toward the surface (30% blend). Ants not carrying anything
bias downward (20% blend) when diggable powder material is detected below.
Both biases are subtle so pheromone-following still shapes overall behavior.
2026-03-11 15:26:48 -04:00
290d27d85f
Place deposited material from ant cargo in world 2026-03-11 15:25:26 -04:00
489f121064
Encode deposit material ID in discretize pass 2026-03-11 15:25:11 -04:00
34bd1e95c2
Add powder material deposit logic for surface placement 2026-03-11 15:24:45 -04:00
43d3e56aee
Add material-aware ant pickup with carry strength check 2026-03-11 15:23:03 -04:00
94b0393abb
Wire material properties texture into AntsComputeScene 2026-03-11 15:22:25 -04:00
cb43ebfe83
Remove food from side-view world initialization 2026-03-11 15:15:57 -04:00
37d615e87a
Fix gravity logic and revert y-flip 2026-03-11 15:15:46 -04:00
7003e074c5
Fix startup world init and inverted sand gravity 2026-03-11 14:55:40 -04:00
8909dc6390
Wire side-view world init into Renderer reset 2026-03-11 14:35:43 -04:00
cd8a4ade82
Spawn ants on sand surface in side view mode
Add VIEW_MODE_SIDE define to common shader defines so shaders can
branch on view mode via preprocessor. In side view, ants init by
scanning downward from the top to find the first non-air cell, then
spawn one pixel above it facing downward. Top view keeps the original
center spawn with random angle.
2026-03-11 14:35:43 -04:00
e89ee1afa2
Add side-view world initialization with sand and sky 2026-03-11 14:35:43 -04:00
e8e5691d83
Add gravity direction and view mode to Config 2026-03-11 14:25:40 -04:00
568bfe83e3
Fix import order in App.ts after lint 2026-03-11 14:18:29 -04:00
bf34de9816
Wire sand physics pass into render pipeline 2026-03-11 14:18:29 -04:00
66d5f6b251
Add SandPhysicsScene for Margolus block CA 2026-03-11 14:18:29 -04:00
9e5af09476
Add Margolus block CA sand physics shader 2026-03-11 14:18:29 -04:00
3787dbbc3a
Add Margolus block offset utility with tests 2026-03-11 14:05:17 -04:00
89f963f9a6
Render world colors from material color lookup texture 2026-03-11 14:01:33 -04:00
29e5dbeb06
Extend draw tools to support material palette
Replace bit-flag draw modes with direct material ID painting. draw.frag
now writes the material ID float directly instead of toggling individual
cell flag bits. ScreenScene drops the PointerState enum in favor of
numeric material IDs from constants.ts, and adds Digit1/Digit2 bindings
for sand and dirt.
2026-03-11 14:01:33 -04:00
e6af97f402
Update ant compute shader for material IDs 2026-03-11 14:01:33 -04:00
e210ebc72d
Update world shader to use material IDs
Replace bit-packed cell flags in the R channel with a direct material ID float pass-through. Food clearing now writes MAT_AIR instead of clearing a bit.
2026-03-11 14:01:33 -04:00
0f9c1b47f2
Replace bit-packed cell flags with material ID constants 2026-03-11 14:01:33 -04:00
f5b04f08c6
Wire material lookup textures into Renderer 2026-03-11 13:43:04 -04:00
a1e164454d
Add GPU lookup texture data generation for materials 2026-03-11 13:43:04 -04:00
bc2c8fa270
Add material registry with built-in materials 2026-03-11 13:43:04 -04:00
dd27634f0c
Add material type definitions 2026-03-11 13:43:04 -04:00
4457368636
Add ant farm sand physics implementation plan 2026-03-11 12:44:24 -04:00
de010adf44
Add ant farm sand physics design doc
Covers GPU Margolus block CA for particle sand, hybrid material
system (shader behaviors + data-driven registry), ant digging/carrying
mechanics, dual camera (side view primary, top-down secondary), and
tiered gravity model (basic -> angle of repose -> pressure propagation).
2026-03-11 12:39:47 -04:00
1c74aabd53
Change from blue to red 2026-03-10 11:04:28 -04:00
a187ccded2
Fix zooming 2026-03-10 11:04:13 -04:00
8ad5130466
Persist config changes in local storage 2026-03-10 10:53:52 -04:00
14208e17fb
Move panels to the left 2026-03-10 10:53:52 -04:00
8dfc6f54bc
Disable culling 2026-03-10 10:53:44 -04:00
eddef83e5b
Add neocities deploy 2026-03-10 10:53:44 -04:00
38 changed files with 4079 additions and 215 deletions

View file

@ -1,6 +1,6 @@
# ants-simulation # ants-simulation
GPU-accelerated ant colony simulation. ants navigate via pheromone trails, all computed in GLSL fragment shaders rendered to offscreen textures. uses WebGL2 features (MRT, GLSL3). GPU-accelerated ant colony simulation. ants navigate via pheromone trails, all computed in GLSL fragment shaders rendered to offscreen textures. uses WebGL2 features (MRT, GLSL3). side-view ant farm with sand/powder physics and material-based world.
## stack ## stack
@ -24,23 +24,24 @@ all simulation logic runs on the GPU via ping-pong render targets. no JS-side si
### render pipeline (per frame) ### render pipeline (per frame)
1. `WorldBlurScene` — diffuse + decay pheromones (3 channels: toHome, toFood, repellent, each with independent blur radius and decay rate) 1. `SandPhysicsScene` — Margolus block CA for sand/powder physics
2. clear `antsPresenceRenderTarget` (ant-ant spatial queries, stub) 2. `WorldBlurScene` — diffuse + decay pheromones (3 channels: toHome, toFood, repellent, blocked by solid cells)
3. `AntsComputeScene` — per-ant state via MRT (writes 2 textures simultaneously) 3. clear `antsPresenceRenderTarget` (ant-ant spatial queries, stub)
4. `AntsDiscretizeScene` — maps continuous ant positions to discrete world grid cells 4. `AntsComputeScene` — per-ant state via MRT (writes 2 textures simultaneously), material-aware digging
5. `WorldComputeScene` — merges ant deposits into world pheromone grid 5. `AntsDiscretizeScene` — maps continuous ant positions to discrete world grid cells
6. `ColonyStats` — CPU readback of ant texture, computes aggregate stats (foragerRatio), feeds back as uniforms 6. `WorldComputeScene` — merges ant deposits into world pheromone grid
7. `DrawScene` — user painting (food, home, obstacles, erase) 7. `ColonyStats` — CPU readback of ant texture, computes aggregate stats (foragerRatio), feeds back as uniforms
8. `ScreenScene` — final composited output with camera controls 8. `DrawScene` — user painting with material palette
9. `ScreenScene` — final composited output with side/top camera views (V to toggle)
### GPU textures ### GPU textures
**ant state** — 2 RGBA Float32 textures per ping-pong target (MRT, `count: 2`): **ant state** — 2 RGBA Float32 textures per ping-pong target (MRT, `count: 2`):
- texture 0: `[pos.x, pos.y, angle, packed(storage << 1 | isCarrying)]` - texture 0: `[pos.x, pos.y, angle, packed(storage << 1 | isCarrying)]`
- texture 1: `[personality, cargoQuality, pathIntDx, pathIntDy]` - texture 1: `[personality, cargoMaterialId, pathIntDx, pathIntDy]`
**world state** — RGBA Float32 (worldSize x worldSize): **world state** — RGBA Float32 (worldSize x worldSize):
- R: packed cell metadata (bits 0-2: food/home/obstacle, bits 3-5: terrain type, bits 6-13: food quality) - R: material ID (0-255), maps to MaterialRegistry entry
- G: scentToHome - G: scentToHome
- B: scentToFood - B: scentToFood
- A: repellent pheromone - A: repellent pheromone
@ -50,27 +51,50 @@ all simulation logic runs on the GPU via ping-pong render targets. no JS-side si
**ant presence** — RGBA Float32 (worldSize x worldSize), cleared each frame. stub for future ant-ant spatial interaction. **ant presence** — RGBA Float32 (worldSize x worldSize), cleared each frame. stub for future ant-ant spatial interaction.
### bit layout (world.R) ### material system
defined in `src/constants.ts`, shared between TS and GLSL via defines: `src/materials/` defines a data-driven material registry. adding a new material requires only a registry entry — no shader changes needed.
- bits 0-2: cell flags (food, home, obstacle)
- bits 3-5: terrain type (0-7, reserved for substrate-dependent decay) - `MaterialRegistry` — 6 built-in materials (ids 0-5): air, sand, dirt, rock, food, home
- bits 6-13: food quality (0-255, reserved for quality-dependent pheromone modulation) - behavior types: `BEHAVIOR_POWDER` (0), `BEHAVIOR_LIQUID` (1), `BEHAVIOR_GAS` (2), `BEHAVIOR_SOLID` (3)
- each material has: id, name, behavior, density, hardness, angleOfRepose, color
- lookup textures (256x1 Float RGBA) uploaded as uniforms to shaders:
- properties texture: `[behavior, density, hardness, angleOfRepose]` per row
- colors texture: `[r, g, b, a]` per row
- material IDs defined in `src/constants.ts` (MAT_AIR through MAT_HOME)
### sand physics
Margolus neighborhood cellular automata runs as the first render pass each frame.
- world is divided into 2x2 blocks; block offset alternates each frame between (0,0) and (1,1)
- within each block, cells with `BEHAVIOR_POWDER` fall downward and slide diagonally, swapping with lighter materials
- physics pass writes back to world ping-pong target before pheromone diffusion runs
- `src/sand/margolus.ts` computes the per-frame block offset (JS side, passed as uniform)
### key files ### key files
- `src/Renderer.ts` — render target creation, pass orchestration, MRT setup, colony stats readback - `src/Renderer.ts` — render target creation, pass orchestration, MRT setup, colony stats readback
- `src/Config.ts` — simulation parameters (per-channel pheromone configs) - `src/Config.ts` — simulation parameters (per-channel pheromone configs)
- `src/constants.ts` — cell metadata bit layout (single source of truth for TS + GLSL) - `src/constants.ts`material IDs (single source of truth for TS + GLSL)
- `src/ColonyStats.ts` — CPU readback of ant texture for colony-level aggregate stats - `src/ColonyStats.ts` — CPU readback of ant texture for colony-level aggregate stats
- `src/StatsOverlay.ts` — on-screen stats display (cursor position, TPS, colony info)
- `src/materials/types.ts` — Material interface and BehaviorType constants
- `src/materials/registry.ts` — MaterialRegistry with 6 built-in materials
- `src/materials/lookupTexture.ts` — builds GPU lookup textures from registry
- `src/sand/margolus.ts` — Margolus block offset calculation
- `src/scenes/SandPhysicsScene.ts` — sand physics render pass
- `src/shaders/antsCompute.frag` — ant behavior + MRT output (2 render targets via layout qualifiers) - `src/shaders/antsCompute.frag` — ant behavior + MRT output (2 render targets via layout qualifiers)
- `src/shaders/worldBlur.frag` — per-channel pheromone diffusion/decay - `src/shaders/worldBlur.frag` — per-channel pheromone diffusion/decay (solid cells block diffusion)
- `src/shaders/world.frag` — cell metadata bit preservation + pheromone merging - `src/shaders/world.frag` — material ID preservation + pheromone merging
- `src/shaders/sandPhysics.frag` — Margolus block CA for powder/sand movement
## planning docs ## planning docs
- `REALISM-IDEAS.md` — research-backed features for more realistic ant behavior - `REALISM-IDEAS.md` — research-backed features for more realistic ant behavior
- `INFRASTRUCTURE.md` — data structure analysis mapping features to GPU infrastructure layers - `INFRASTRUCTURE.md` — data structure analysis mapping features to GPU infrastructure layers
- `docs/plans/2026-03-11-ant-farm-sand-physics-design.md` — sand physics design
- `docs/plans/2026-03-11-ant-farm-implementation.md` — ant farm implementation plan
## shader files ## shader files

244
docs/NEST-BUILDING.md Normal file
View file

@ -0,0 +1,244 @@
NEST BUILDING AND COLONY ARCHITECTURE
======================================
research notes for informing ant farm simulation behavior.
sources cited inline.
INITIAL NEST FOUNDING
---------------------
after a mating flight, a queen sheds her wings, finds a suitable spot, and
digs a "founding chamber" -- a small cavity just big enough to tend eggs and
larvae. she seals the entrance and doesn't leave until the first workers emerge.
she feeds larvae with trophic (unfertilized) eggs laid specifically as food.
founding chamber size: ~24 cm^2 in Camponotus fellah studies.
what triggers digging location: queens prefer softer, more humid soil. in
shaded habitats (less dense, more humid soil), queen establishment was greater,
nest depth was greater, and excavated volume was greater. soil density and
humidity are the primary abiotic factors.
[eLife: colony demographics shape nest construction]
[Wiley: soil density and humidity in leaf-cutting ant queens]
TUNNEL ARCHITECTURE
-------------------
shape transition: ants first excavate a circular chamber. once it reaches a
critical area, buds appear along the chamber wall and tunnels branch off. this
is a density-driven phase transition:
high ant density along wall = uniform digging (chamber grows)
low ant density per unit wall = localized instabilities = tunnels branch
this is the key insight from Toffin et al. (PNAS 2009).
tunnel descent angle: ants dig at roughly the angle of repose of the substrate
(~40 degrees for typical sand). they don't exceed this -- "if I'm a digger, my
digging technique is going to align with the laws of physics, otherwise my
tunnels collapse and I die." (Caltech/PNAS 2021)
branching complexity increases with population size. new tunnels branch from
existing tunnel walls, not just from chambers.
moisture influence: tunnels extend vertically until hitting either the surface
or a soil layer with sharply increasing water content. ants excavate more in
moist soil but stop ~12mm above water-saturated layers.
gravity's role: gravity promotes vertical motion and accounts for the formation
of ellipsoidal chambers and vertical tunnels. it acts as a "template" for
tunnel direction.
[PNAS: shape transition during nest digging in ants (Toffin 2009)]
[PLOS ONE: role of colony size on tunnel branching]
[PNAS: unearthing real-time 3D ant tunneling mechanics (2021)]
[Nature Sci Reports: ant nest architecture shaped by temperature]
[PMC: soil moisture and excavation behaviour]
DIGGING MECHANICS
-----------------
ants carry particles one at a time in their mandibles. they prefer smaller
grains. most effective excavation occurs when grains are small enough to carry.
why tunnels don't collapse: intergranular forces decrease around ant tunnels
due to "granular arching" -- force chains in the soil redistribute around the
tunnel, forming natural arches. any grain an ant picks from the tunnel face is
already under low stress due to this force relaxation. ants achieve stability
without reinforcements purely by benefiting from physics. the arch structures
have a greater diameter than the tunnel itself.
digging pattern: piecewise linear tunnel segments. ants dig in short straight
stretches, then change direction slightly.
excavated material deposition: ants carry grains to the surface and deposit
them near the nest entrance, forming a mound/midden. in Pheidole oxyops, the
pile forms a crescent shape ~13cm from the entrance. 82% of grains stay where
dropped; 18% roll downhill. in leaf-cutter ants, soil pellets are transported
sequentially over 2m involving up to 12 workers in a relay chain.
[PNAS: ants make efficient excavators]
[ResearchGate: sand pile formation in Dorymyrmex ants]
[PLOS ONE: sequential soil transport in leaf-cutting ants]
DIVISION OF LABOR IN CONSTRUCTION
----------------------------------
age polyethism: young ants do most of the digging. older ants forage more.
young vs old digging style: young ants dig slanted tunnels. old ants dig
straight down. this is a form of age polyethism where age dictates not just
task likelihood but task execution style.
emergency override: after a nest catastrophe (collapse, flooding), all ants
dig regardless of age to restore lost nest volume.
queen's role: digs only during founding. once first workers emerge, she never
digs again. focuses exclusively on reproduction. her pheromones influence
colony-wide behavior including construction coordination.
coordination: no central planning. workers follow chemical markers ("building
pheromone") on substrate indicating active construction zones. substrate
vibrations may also coordinate digging underground.
[eLife: colony demographics shape nest construction]
[Springer: task allocation in ant colonies]
[Knowable Magazine: evolution of division of labor]
COLONY GROWTH AND NEST EXPANSION
---------------------------------
scaling: nest volume grows logistically to a saturation volume that scales
linearly with population size. larger colony = proportionally larger nest.
timing: population increase is followed ~15 days later by an increase in
excavated area. nest expansion lags population growth by roughly one week.
the nest is excavated in discrete digging episodes triggered by population
increases.
expansion pattern: three simultaneous processes:
1. nest deepening (main shaft extends)
2. chamber magnification (existing chambers enlarge)
3. creation of new vertical chamber sequences (new branches)
trigger: population crowding. track (colony population) / (excavated area).
when this exceeds a threshold, digging episode begins.
[eLife: colony demographics shape nest construction]
[bioRxiv: colony demographics shape nest construction (preprint)]
ANT FARM (2D) SPECIFICS
------------------------
the formicarium was invented by Charles Janet (French entomologist) -- reducing
3D nest architecture to virtual 2D between two panes of glass.
the gap between panes should match the diameter of the species' typical burrow,
so no soil layer sticks to the glass. forces all tunneling to be visible.
climbing on glass: ants CAN walk on vertical glass surfaces. they use adhesive
pads (arolia) on legs above their center of mass (pulling) and dense tarsal
hair arrays on legs below center of mass (pushing). anti-escape coatings (PTFE,
petroleum jelly) are used on formicarium rims specifically because ants climb
glass easily.
gravity in 2D: ants walk on the glass surface while digging in substrate
between the panes. sand/soil is subject to gravity and will collapse if not
supported. the angle-of-repose constraint still applies.
behavioral differences: ants exhibit the same chamber-first-then-tunnel
transition, same density-driven branching, same age-based digging allocation
in 2D as 3D. the main difference is the architecture is forced planar.
[Wikipedia: formicarium]
[AntKeepers: classic ant farm]
[PLOS ONE: on heels and toes -- how ants climb]
WITHOUT A QUEEN
---------------
survival: 3-4 months typical, up to 12 months in good conditions.
behavioral changes: workers search for the missing queen. stress increases.
aggression increases (queen pheromones normally maintain harmony). social
organization deteriorates.
still dig? yes. workers continue to excavate, reinforce, and maintain the nest.
reproduction: some workers begin laying unfertilized eggs (males only). in
some species, workers engage in dominance tournaments and winners become
"pseudoqueens" with dramatically increased lifespans (7 months to 4 years).
no new workers: colony cannot produce workers without a queen. slow decline.
no larvae also means nutrition problems since larvae help digest solid food
for the colony.
[MisfitAnimals: how long do ants live without a queen]
[BestAntsUK: ant farm essentials -- how crucial is a queen]
[NYU: dueling ants vying to become replacement queen]
EMERGENT PATTERNS AND STIGMERGY
--------------------------------
stigmergy: indirect coordination through environmental modification. an ant's
action changes the environment, which stimulates the next ant's action. no
direct communication needed. coined by Grasse (1959) studying termites.
building pheromone: ants deposit a pheromone on building material/substrate.
this has a limited lifetime, creating a spatially heterogeneous "topochemical
landscape." areas with fresh pheromone attract more building/digging activity.
without pheromone dynamics, no coherent structure can be built in simulations.
(Khuong et al., PNAS 2016)
pheromone lifetime is key: controls the growth and form of nest architecture.
short lifetime = more localized construction (tight tunnels)
long lifetime = more diffuse construction
template + stigmergy hybrid: two interactions coordinate building:
1. stigmergic: building pheromone on substrate
2. template-based: ants use body size as cue to control tunnel/chamber
dimensions (e.g. roof height)
density-driven transitions (restated): high local ant density = chamber
expansion. low local density = tunnel formation. the chamber-to-tunnel
transition emerges from this simple density rule.
simple rules produce complex nests. the combination of:
a. building pheromone deposition/decay
b. local density sensing
c. gravity alignment
d. body-size templates
produces the full range of observed nest architectures. no ant has a blueprint.
[PNAS: stigmergic construction and topochemical information (Khuong 2016)]
[Wikipedia: stigmergy]
[PNAS: shape transition during nest digging (Toffin 2009)]
[eLife/PMC: emergent regulation of ant foraging]
SIMULATION PARAMETERS (DISTILLED)
----------------------------------
key values that map to tunable config/uniforms:
tunnel descent angle ~40 degrees (angle of repose of substrate)
chamber-tunnel transition ant density per unit wall drops below threshold
building pheromone decay controls architecture compactness
nest volume / population linear scaling, ~24 cm^2 per founding queen
young ant digging bias slanted tunnels vs vertical (age polyethism)
material deposit location near surface entrance, biased away from it
grain removal rate one grain per trip, prefer lighter grains
expansion trigger population / excavated area exceeds threshold
moisture preference dig toward moist, stop before saturated
the ant presence texture (already exists as stub) combined with a digging
pheromone channel would give us the chamber-then-tunnel transition for free.

View file

@ -0,0 +1,155 @@
Ant Behavior Overhaul — Design
===============================
goal: fix broken ant physics, add gravity and surface movement, unify the
brush tool so everything (including ants) is droppable, establish a behavioral
priority stack, and lay groundwork for emergent nest architecture.
reference: docs/NEST-BUILDING.md (research on real ant nest construction)
1. Ant Physics — Gravity and Surface Movement
----------------------------------------------
problem: ants float through air with no physics. they walk through sand and
rise upward forever when carrying.
two physical rules added to antsCompute.frag:
GRAVITY: if the cell below an ant is air, the ant falls one cell downward.
falling ants don't steer — they just drop. this makes dropped ants tumble
from wherever you place them.
SURFACE CONSTRAINT: ants can only walk (apply their steering angle) when
they're adjacent to a solid material cell. "adjacent" means any of the 4
cardinal neighbors is non-air. if an ant is on top of sand, next to a tunnel
wall, or on the world boundary — it can walk. if it's in open air with nothing
nearby, it falls.
ANT-SAND COLLISION: sand physics runs first (pass 1), ants compute runs later
(pass 4). if falling sand fills an ant's cell between frames, the ant gets
displaced to the nearest air cell (check up first, then sideways). sand always
wins, ants get squeezed out. this prevents deadlock where an ant and a grain
occupy the same cell.
MOVEMENT THROUGH MATERIALS: ants treat all non-air materials as solid walls
they can't walk into, UNLESS they're actively digging (picking up a powder
cell they're facing). walking into rock, food, or home blocks movement.
2. Unified Brush — Everything is Droppable
-------------------------------------------
problem: ants are created at init time only. materials and ants use separate
systems. want Sandboxels-style "pick element, drop it."
the brush palette becomes a single list of elements: erase, sand, dirt, rock,
food, home, ants. all use the same brush size slider. brush size = number of
cells filled per click/drag frame.
FOR MATERIALS (sand, dirt, rock, food, home, erase): works like today.
draw.frag writes the material ID into world cells within the brush radius.
FOR ANTS: clicking writes to a separate "ant spawn" buffer — a small queue of
(x, y) positions. each frame, the ant compute pass checks this buffer and
activates inactive ants (those still at pos == vec2(0)) at those positions.
brush size N = activate N ants at/near the click position, randomly scattered
within the brush radius.
ANT BUDGET: a config value antBudget (default 512) sets the texture size. the
starting count antsStartCount (default 4) determines how many activate on init.
drawing ants activates more from the pool. if the pool is full, drawing does
nothing.
GUI CHANGES:
- material palette gets an "ants" entry with keybind (A)
- brush radius slider controls all elements
- antsStartCount replaces the 2^N slider — plain number (0-64)
- antBudget is not in the main GUI (advanced/hidden config)
SEED TOGGLE: a checkbox "seed home + food" (default on). when on, world init
places one home and one food at random surface positions. when off, blank sand
and sky. lives in GUI under world section.
3. Ant Brain — Behavioral Drives
----------------------------------
PHASE A (this implementation):
each ant has a priority stack, evaluated top to bottom each frame:
1. FALLING — if no solid neighbor below, fall. skip all other logic.
2. CARRYING, DEPOSIT — if carrying food and at home, drop it. if carrying
sand/dirt and on the surface (air above, solid below), drop it.
3. CARRYING, NAVIGATE — if carrying food, follow toHome pheromone. if carrying
sand/dirt, bias upward toward surface.
4. NOT CARRYING, FORAGE — if food scent detected, follow toFood pheromone.
5. NOT CARRYING, DIG — if adjacent to diggable powder below the surface, pick
it up. prefer digging downward at roughly the angle of repose (~40 degrees).
6. NOT CARRYING, WANDER — random walk along surfaces. the fallback.
SUPPRESSORS (every drive has a reason NOT to fire):
- digging suppresses when on the surface or material is too hard
- foraging suppresses when no food scent in sample range
- deposit suppresses when no solid ground below drop point
- wandering is the fallback — only fires when nothing else triggers
WHAT GIVES ANTS PURPOSE WITHOUT A QUEEN: home marker acts as anchor. ants
forage food back to home. ants dig near home to create shelter. the home
pheromone radiates outward and gives ants a "come back here" signal. without
a home marker, ants just wander and react to whatever they're near.
PHASE B (future — digging pheromone):
ants deposit a "dig here" scent when excavating, attracting other ants to dig
nearby. this creates the density-driven chamber-to-tunnel transition from
the nest-building research. the repellent pheromone channel (A channel) could
be repurposed or a 5th channel added. pheromone decay rate controls tunnel
compactness: short lifetime = tight tunnels, long lifetime = diffuse.
PHASE C (future — colony dynamics):
age parameter on ants (personality field in texture 1 repurposed or extended).
young ants dig more, old ants forage more. colony growth = activating new ants
from the budget when food-delivered count exceeds a threshold. nest expansion
triggered when population/excavated-area ratio exceeds threshold. emergency
"all hands dig" mode when nest area drops below expected-per-population.
4. Sand Color Variation
------------------------
problem: sand looks flat — every cell is the same color.
in screenWorld.frag, when rendering a non-air material, hash the cell's grid
position to produce a small color offset:
vec3 baseColor = materialColorLookup(materialId);
float noise = hash(ivec2(gl_FragCoord.xy)) * 0.08 - 0.04; // +/-4%
baseColor += noise;
spatial and deterministic (same cell always looks the same). no flickering.
works for all materials automatically — dirt gets subtle variation, rock gets
speckle.
5. Config and Persistence
--------------------------
new/changed config defaults:
antsStartCount 4 slider 0-64, plain number. replaces antsCount.
antBudget 512 hidden. texture size = ceil(sqrt(antBudget)).
seedWorld true checkbox. "seed home + food" toggle.
brushElement "sand" palette buttons. replaces brushMaterial.
includes "ants" as an option.
REMOVED: antsCount (the 2^N exponent).
TEXTURE SIZING: antBudget determines the ant texture dimensions.
512 -> sqrt(512) ~ 23 -> 23x23 = 529 slots. ants at index >= antBudget stay
at (0,0) and never activate. starting ants (0 to antsStartCount-1) get
initialized on first frame. drawing ants activates the next available slot.
LOCAL STORAGE: same pattern as today — only non-default values saved.
antsStartCount and seedWorld persist. antBudget probably shouldn't persist
(changing it requires re-creating textures on reload).

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,245 @@
# Ant Farm with Sand Physics
Design for transforming the ant colony simulation into a side-view ant farm with GPU-accelerated particle sand physics, a data-driven material system, and dual camera support.
## Vision
A 2D cross-section ant farm — sand below, sky above, ants on the surface. Ants dig tunnels by picking up sand grains in their mandibles and carrying them to the surface. Sand falls under gravity, tunnels can collapse, and different materials behave differently. Side view is the primary experience. The world is a sandbox — eventually any element (including ants) can be dropped in by the user.
## World Model
- 2D grid, same as current. Y-axis is vertical, gravity pulls -Y
- Side view is primary camera. Top-down is secondary (surface projection, like a minimap)
- Default scene: horizontal split — solid sand below, empty air above, ants placed on sand surface
- World stays 2D — no 3D volume. An ant farm is inherently a thin cross-section
### Why Not 3D
A 512^3 Float32 volume = ~2GB VRAM per buffer (need 2 for ping-pong = 4GB). Fragment shaders operate on 2D surfaces — updating a 3D volume requires compute shaders (WebGPU, not WebGL2). The ant farm metaphor is naturally 2D, so this is both the simpler and architecturally correct choice.
## Sand Physics — Margolus Block Cellular Automata
### The GPU Read-Write Problem
Naive falling sand on GPU: two grains try to fall into the same empty cell simultaneously, both read "empty", both write themselves there. Data race.
### Solution: Margolus Neighborhood
Divide the grid into non-overlapping 2x2 blocks. Each block updates independently — the 4 cells within a block can swap/rearrange without conflicting with other blocks. Alternate the block offset each frame (even frames: blocks at 0,0 / 2,0 / 0,2...; odd frames: blocks at 1,1 / 3,1 / 1,3...). Over two frames, every cell interacts with all its neighbors.
References:
- GPU-Falling-Sand-CA: https://github.com/GelamiSalami/GPU-Falling-Sand-CA
- Writeup: https://meatbatgames.com/blog/falling-sand-gpu/
- falling-sand-shader: https://github.com/m4ym4y/falling-sand-shader
### Gravity Tiers
#### Tier 1: Basic Falling Sand (starting point)
- Grains fall down if cell below is empty (or lower density)
- If blocked below, try diagonal-down (randomize left/right to prevent bias)
- Density-based displacement: heavier materials sink through lighter ones probabilistically
#### Tier 2: Angle of Repose
- Grains only slide diagonally if local slope exceeds material-specific angle
- Sand: ~34 degrees, dirt: ~40 degrees, gravel: steeper
- Creates realistic pile shapes and mound formation
- Implementation: probability tweak on the diagonal check based on neighbor occupancy in a wider radius
#### Tier 3: Pressure and Weight Propagation (dream goal)
- Cells feel weight of material above
- Unsupported overhangs collapse under load
- Granular arching: force chains form naturally around voids, stabilizing tunnels
- Matches real ant tunnel physics (Caltech PNAS 2021 research)
- Requires additional simulation pass for pressure propagation
## Material System — Hybrid Architecture
### Core Behaviors (shader-native)
Four physics behavior types baked into GLSL:
- **POWDER** — falls under gravity, piles up (sand, dirt, gravel)
- **LIQUID** — falls + spreads horizontally, viscosity parameter (water, mud)
- **GAS** — expands omnidirectionally, rises (air, smoke)
- **SOLID** — immovable, no physics (rock, bedrock, obstacles)
### Material Properties (data-driven)
Defined in a TypeScript registry, uploaded to GPU as a lookup texture:
- density (determines displacement order and fall speed)
- color / color variation range
- behavior type (powder / liquid / gas / solid)
- hardness (resistance to ant digging, resistance to degradation)
- angle of repose (tier 2)
- reactions (material A + material B -> material C, with probability)
Adding a new material = adding a registry entry. No shader changes unless it needs a new behavior type.
### Material Lookup on GPU
Material ID stored per cell. Shader samples a 1D lookup texture (or small 2D atlas) indexed by material ID to get density, behavior type, color, etc. Keeps the fragment shader branching minimal — switch on behavior type (4 branches), read properties from texture.
### Starting Materials
- air (empty)
- sand (powder, density 1.5, color tan with variation)
- dirt (powder, density 1.3, color brown)
- rock (solid, density 2.5, color gray — immovable)
- food (solid-ish, density 1.0, color green — pickup target)
- home marker (solid, density 0, color blue — nest entrance)
### Future Materials (post-launch, Sandboxels-inspired)
- water (liquid), wet sand (powder, higher cohesion), mud (liquid, high viscosity)
- gravel (powder, high density, large angle of repose)
- clay (powder, very cohesive), packed sand (solid until disturbed)
- rock wall, bedrock (indestructible solid)
## Ant-Material Interaction
### Carrying Mechanic
- Ants pick up material grains with mandibles (same gesture as food carrying)
- Each ant has a carry strength value
- Each material has a weight value
- Ant can pick up and carry any material where weight < ant strength
- When carrying, ant moves the grain from source cell to wherever it drops it
### Multi-Ant Cooperation
- Multiple ants adjacent to the same heavy grain combine their strength
- If combined strength > material weight, one ant picks it up
- Emergent behavior — ants don't coordinate intentionally, but spatial clustering near obstacles creates natural cooperation events
### What Ants Do With Carried Material
- Excavated grains carried to surface and deposited near tunnel entrance (forms anthill mound)
- Drop location follows pheromone-like heuristic: away from entrance, near existing mound
- If tunnel dead-ends or ant is confused, may deposit grain in-tunnel (creates natural backfill)
### Material Degradation (later enhancement)
- Persistent ant activity at a material face slowly degrades it
- rock -> gravel -> sand (over many ant-ticks of contact)
- Multiple ants at same face speeds up degradation
- Enables ants to eventually get through anything given enough time and workers
## Digging Behavior
### How Ants Decide Where to Dig
Based on real ant research — digging is NOT pheromone-guided:
- Ants probe for loose/low-stress grains (prefer cells with fewer occupied neighbors)
- Negative feedback on tunnel length: less digging in already-long tunnels
- Colony size drives branching complexity
- Individual ants have no blueprint — structure emerges from local rules
### Tunnel Properties
- Tunnels follow angle of repose for current material
- Branching increases with colony size
- Chambers form at tunnel intersections or where ants cluster
- Specialized chambers emerge from ant behavior patterns (food storage near food sources, etc.)
## Camera System
### Side View (primary, new default)
- Orthographic camera, Y-up, gravity is -Y
- Full view of the cross-section: sky, surface, underground
- Zoom and pan preserved from current controls
- This IS the ant farm experience
### Top-Down View (secondary)
- Shows just the surface layer (top row of occupied cells + some depth)
- Like looking down at the ground above the ant farm
- Useful for seeing surface foraging patterns, anthill mound shape
- Implementation: either a camera rotation or a separate render pass sampling the top-N rows
### Stats Overlay
- Cursor position (x, y grid coords)
- Active particle count
- TPS (ticks per second, simulation steps)
- Material under cursor
- Ant count, carrying count
## Render Pipeline Changes
Current pipeline is pheromone-focused. New pipeline adds sand physics pass:
1. **SandPhysicsScene** (NEW) — Margolus block CA pass, updates material grid
2. **WorldBlurScene** — pheromone diffusion/decay (unchanged conceptually, operates on pheromone channels only)
3. **AntsComputeScene** — ant behavior, now includes dig/carry logic and material awareness
4. **AntsDiscretizeScene** — maps ant state to grid (now includes material deposits from carrying ants)
5. **WorldComputeScene** — merges ant changes into world (pheromone deposits + material modifications)
6. **ColonyStats** — CPU readback for aggregate stats
7. **DrawScene** — user painting, now supports material palette (not just food/home/obstacle)
8. **ScreenScene** — final composite, side-view camera, material coloring
### Texture Layout Changes
The world texture needs to encode material ID per cell instead of just bit flags:
**Current world R channel:** bit-packed flags (food, home, obstacle, terrain type, food quality)
**New world encoding (proposal):**
- R: material ID (0-255, indexes into material lookup texture)
- G: scentToHome (pheromone, unchanged)
- B: scentToFood (pheromone, unchanged)
- A: repellent pheromone (unchanged)
Material ID replaces the bit-packed flags. Food, home, obstacle become materials in the registry instead of bit flags. This is cleaner and scales to unlimited material types.
The material lookup texture (1D, 256 entries) stores per-material: behavior type, density, color RGBA, hardness, angle of repose, flags.
## Testing Strategy
Reasonable, targeted tests — not exhaustive coverage of every edge case.
### Unit Tests
- Material registry: adding materials, looking up properties, validation
- Margolus block offset calculation (even/odd frame alternation)
- Ant strength vs material weight threshold logic
- Coordinate transforms between side view and world grid
### Integration Tests
- Sand grain falls through empty space and stops on solid ground
- Ant picks up sand, carries it, deposits it (full carry cycle)
- Material lookup texture generation matches registry data
- Camera switching between side and top-down views
### Visual / Manual Tests
- Sand piling behavior looks natural (no directional bias)
- Ants visibly dig tunnels that persist
- Tunnel collapse when support removed
- Draw tool places correct materials
### What We Don't Test
- Individual shader math (tested visually, not worth the harness)
- Every material combination reaction
- Performance benchmarks (profile manually when needed)
- UI layout/styling
## Migration Path
The existing simulation keeps working throughout. Changes are additive:
1. Add material system + lookup texture alongside existing world encoding
2. Add sand physics pass (new, doesn't touch existing passes)
3. Modify ant compute to understand materials (extend, don't rewrite)
4. Modify screen rendering for side view + material colors
5. Add camera toggle for top-down
6. Swap default init from top-down food/home to ant-farm sand/sky scene
## Open Questions
- Pheromone behavior in side view: do pheromones diffuse through sand or only through air/tunnels? (probably only air — sand blocks diffusion)
- Should food sources appear on the surface (above ground) or embedded in sand (underground deposits)?
- Ant spawning: do ants emerge from a queen chamber underground, or appear at the surface home marker?
- How wide vs tall should the default world aspect ratio be? Currently square (1024x1024). Wider for more horizontal foraging space?
## References
### GPU Falling Sand
- GPU-Falling-Sand-CA: https://github.com/GelamiSalami/GPU-Falling-Sand-CA
- Blog writeup: https://meatbatgames.com/blog/falling-sand-gpu/
- falling-sand-shader: https://github.com/m4ym4y/falling-sand-shader
- Sandspiel (Rust+WebGL): https://maxbittker.com/making-sandspiel/
### Real Ant Digging Research
- Caltech: The Science of Underground Kingdoms (granular arching)
- PNAS 2021: Unearthing real-time 3D ant tunneling mechanics
- PLOS ONE: The Role of Colony Size on Tunnel Branching Morphogenesis
- Key finding: ants sense grain stress like Jenga — remove loose grains, arches self-stabilize
### Sandboxels (neal.fun)
- Source: R74nCom/sandboxels on GitHub
- Key patterns: sparse pixel storage, random tick order, density-based displacement, behavior type system (POWDER/LIQUID/GAS), reaction registry
- All CPU — our GPU approach is architecturally different but the material/behavior model is directly applicable

View file

@ -8,29 +8,135 @@
body { body {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
display: flex;
} }
canvas { #sidebar {
width: 100%; width: 220px;
height: 100%; min-width: 220px;
height: 100vh;
background: #0a0a0a;
overflow-y: auto;
display: flex;
flex-direction: column;
} }
#info { #info {
position: absolute;
top: 0;
left: 0;
width: 100%;
text-align: left;
font-family: monospace; font-family: monospace;
color: white; color: #777;
padding: 16px; padding: 12px;
text-shadow: 0 0 3px black; font-size: 12px;
pointer-events: none; line-height: 2;
border-bottom: 1px solid #1a1a1a;
}
#info kbd {
display: inline-block;
background: #1a1a1a;
color: #c43c3c;
padding: 1px 5px;
border-radius: 3px;
font-family: monospace;
font-size: 11px;
}
#info .label {
display: inline-block;
width: 65px;
}
#stats {
font-family: monospace;
color: #777;
padding: 8px 12px;
font-size: 11px;
line-height: 1.8;
border-bottom: 1px solid #1a1a1a;
}
#stats .label {
display: inline-block;
width: 65px;
color: #555;
}
#stats .value {
color: #c43c3c;
}
.reset-btn {
width: calc(100% - 16px);
margin: 12px 8px;
padding: 6px;
background: #141414;
color: #666;
border: 1px solid #2a2a2a;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
cursor: pointer;
}
.reset-btn:hover {
background: #1a1a1a;
color: #c43c3c;
}
#gui-container {
flex: 1;
}
/* lil-gui noir + red theme */
#gui-container .lil-gui {
--background-color: #0a0a0a;
--widget-color: #1a1a1a;
--hover-color: #222;
--focus-color: #2a2a2a;
--number-color: #c43c3c;
--string-color: #c43c3c;
--text-color: #888;
--title-background-color: #0f0f0f;
--title-text-color: #999;
--folder-indent: 8px;
}
/* lil-gui overrides: fill sidebar, labels on top */
#gui-container .lil-gui.root {
width: 100% !important;
position: static;
}
#gui-container .lil-gui .controller {
flex-wrap: wrap;
}
#gui-container .lil-gui .controller .name {
width: 100%;
min-width: 100%;
padding-bottom: 2px;
}
#gui-container .lil-gui .controller .widget {
min-width: 100%;
}
canvas {
flex: 1;
min-width: 0;
height: 100vh;
} }
</style> </style>
</head> </head>
<body> <body>
<div id="info">Controls:<br/>Q - draw home cells<br/>W - draw food cells<br/>E - draw obstacle<br/>R - erase<br/>Drag and scroll to move the camera</div> <div id="sidebar">
<div id="info">
<kbd>Q</kbd> <span class="label">home</span><kbd>W</kbd> food<br/>
<kbd>E</kbd> <span class="label">obstacle</span><kbd>R</kbd> erase<br/>
<kbd>V</kbd> toggle view mode<br/>
drag + scroll to move camera
</div>
<div id="gui-container"></div>
</div>
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>
<script type="module" src="/src/App.ts"></script> <script type="module" src="/src/App.ts"></script>
</body> </body>

View file

@ -1,6 +1,9 @@
default: default:
@just --list @just --list
dev:
bun run dev
lint: lint:
bun run lint bun run lint
@ -11,3 +14,9 @@ test:
bun run test bun run test
check: lint typecheck test check: lint typecheck test
build:
bun run build
neocities: build
bashcities -n -p ants push

View file

@ -1,9 +1,11 @@
import Config from "./Config"; import Config, { saveConfig } from "./Config";
import GUI from "./GUI"; import GUI from "./GUI";
import Renderer from "./Renderer"; import Renderer from "./Renderer";
import StatsOverlay from "./StatsOverlay";
import AntsComputeScene from "./scenes/AntsComputeScene"; import AntsComputeScene from "./scenes/AntsComputeScene";
import AntsDiscretizeScene from "./scenes/AntsDiscretizeScene"; import AntsDiscretizeScene from "./scenes/AntsDiscretizeScene";
import DrawScene from "./scenes/DrawScene"; import DrawScene from "./scenes/DrawScene";
import SandPhysicsScene from "./scenes/SandPhysicsScene";
import ScreenScene from "./scenes/ScreenScene"; import ScreenScene from "./scenes/ScreenScene";
import WorldBlurScene from "./scenes/WorldBlurScene"; import WorldBlurScene from "./scenes/WorldBlurScene";
import WorldComputeScene from "./scenes/WorldComputeScene"; import WorldComputeScene from "./scenes/WorldComputeScene";
@ -15,6 +17,7 @@ export interface SceneCollection {
discretize: AntsDiscretizeScene; discretize: AntsDiscretizeScene;
screen: ScreenScene; screen: ScreenScene;
draw: DrawScene; draw: DrawScene;
sandPhysics: SandPhysicsScene;
} }
export default new (class App { export default new (class App {
@ -23,12 +26,14 @@ export default new (class App {
); );
private scenes!: SceneCollection; private scenes!: SceneCollection;
private gui: GUI = new GUI(); private gui: GUI = new GUI();
private statsOverlay: StatsOverlay = new StatsOverlay();
private renderLoop = (time: number): void => this.render(time); private renderLoop = (time: number): void => this.render(time);
private lastTime: number = 0; private lastTime: number = 0;
private queuedSimSteps: number = 0; private queuedSimSteps: number = 0;
constructor() { constructor() {
this.initScenes(); this.initScenes();
this.resetRenderer();
window.addEventListener("resize", () => this.resize()); window.addEventListener("resize", () => this.resize());
@ -39,6 +44,15 @@ export default new (class App {
this.gui.on("reset", () => { this.gui.on("reset", () => {
this.resetRenderer(); this.resetRenderer();
}); });
this.gui.on("zoomChange", () => {
this.scenes.screen.applyCameraZoom();
});
window.addEventListener("viewModeToggle", () => {
saveConfig();
this.resetRenderer();
});
} }
private resetRenderer() { private resetRenderer() {
@ -53,12 +67,14 @@ export default new (class App {
discretize: new AntsDiscretizeScene(this.renderer), discretize: new AntsDiscretizeScene(this.renderer),
screen: new ScreenScene(this.renderer), screen: new ScreenScene(this.renderer),
draw: new DrawScene(this.renderer), draw: new DrawScene(this.renderer),
sandPhysics: new SandPhysicsScene(this.renderer),
}; };
} }
private resize() { private resize() {
const width = window.innerWidth * window.devicePixelRatio; const canvas = this.renderer.canvas;
const height = window.innerHeight * window.devicePixelRatio; const width = canvas.clientWidth * window.devicePixelRatio;
const height = canvas.clientHeight * window.devicePixelRatio;
this.renderer.resizeCanvas(width, height); this.renderer.resizeCanvas(width, height);
@ -73,6 +89,7 @@ export default new (class App {
} }
this.renderer.renderSimulation(this.scenes); this.renderer.renderSimulation(this.scenes);
this.statsOverlay.recordTick();
} }
private render(time: number) { private render(time: number) {
@ -96,6 +113,11 @@ export default new (class App {
this.renderer.renderToScreen(this.scenes); this.renderer.renderToScreen(this.scenes);
this.statsOverlay.update(
this.scenes.screen.pointerPosition,
this.renderer.colonyStatsData,
);
this.lastTime = time; this.lastTime = time;
} }
})(); })();

View file

@ -1,4 +1,6 @@
export default { const STORAGE_KEY = "ants-simulation-config";
const defaults = {
worldSize: 1024, worldSize: 1024,
antsCount: 12, antsCount: 12,
simulationStepsPerSecond: 60, simulationStepsPerSecond: 60,
@ -11,9 +13,51 @@ export default {
antSpeed: 1, antSpeed: 1,
antRotationAngle: Math.PI / 30, antRotationAngle: Math.PI / 30,
brushRadius: 20, brushRadius: 20,
brushMaterial: -1,
cameraZoom: 0,
gravityDirection: "down" as const,
viewMode: "side" as "side" | "top",
// per-channel pheromone params // per-channel pheromone params
repellentFadeOutFactor: 0.0005, repellentFadeOutFactor: 0.0005,
repellentBlurRadius: 0.05, repellentBlurRadius: 0.05,
repellentMaxPerCell: 10, repellentMaxPerCell: 10,
repellentThreshold: 0.01, repellentThreshold: 0.01,
}; };
type ConfigType = typeof defaults;
function loadSaved(): Partial<ConfigType> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {
// corrupted data, ignore
}
return {};
}
const saved = loadSaved();
const Config: ConfigType = { ...defaults, ...saved };
export function saveConfig(): void {
const toSave: Partial<ConfigType> = {};
for (const key of Object.keys(defaults) as (keyof ConfigType)[]) {
if (Config[key] !== defaults[key]) {
// biome-ignore lint/suspicious/noExplicitAny: generic key/value copy
(toSave as any)[key] = Config[key];
}
}
if (Object.keys(toSave).length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} else {
localStorage.removeItem(STORAGE_KEY);
}
}
export function resetConfig(): void {
Object.assign(Config, defaults);
localStorage.clear();
}
export { defaults };
export default Config;

View file

@ -1,11 +1,19 @@
import GUI from "lil-gui"; import GUI from "lil-gui";
import Config from "./Config"; import Config, { resetConfig, saveConfig } from "./Config";
import {
MAT_AIR,
MAT_DIRT,
MAT_FOOD,
MAT_HOME,
MAT_ROCK,
MAT_SAND,
} from "./constants";
type EventHandler = () => void; type EventHandler = () => void;
class GUIController { class GUIController {
private gui: GUI = new GUI({ private gui: GUI = new GUI({
width: 400, container: document.getElementById("gui-container") as HTMLElement,
}); });
private listeners: Map<string, EventHandler[]> = new Map(); private listeners: Map<string, EventHandler[]> = new Map();
@ -16,33 +24,94 @@ class GUIController {
.add(Config, "worldSize", 256, 4096) .add(Config, "worldSize", 256, 4096)
.name("World size") .name("World size")
.step(1) .step(1)
.onChange(() => this.emit("reset")); .onChange(() => this.saveAndEmit("reset"));
simFolder simFolder
.add(Config, "antsCount", 0, 22) .add(Config, "antsCount", 0, 22)
.name("Ants count 2^") .name("Ants count 2^")
.step(1) .step(1)
.onChange(() => this.emit("reset")); .onChange(() => this.saveAndEmit("reset"));
simFolder simFolder
.add(Config, "scentFadeOutFactor", 0, 0.01) .add(Config, "scentFadeOutFactor", 0, 0.01)
.name("Pheromone evaporation factor") .name("Pheromone evaporation factor")
.step(0.0001) .step(0.0001)
.onChange(() => this.emit("reset")); .onChange(() => this.saveAndEmit("reset"));
simFolder simFolder
.add(Config, "scentBlurRadius", 0, 0.5) .add(Config, "scentBlurRadius", 0, 0.5)
.name("Pheromone diffusion factor") .name("Pheromone diffusion factor")
.step(0.01) .step(0.01)
.onChange(() => this.emit("reset")); .onChange(() => this.saveAndEmit("reset"));
simFolder simFolder
.add(Config, "simulationStepsPerSecond", 1, 500) .add(Config, "simulationStepsPerSecond", 1, 500)
.name("Simulation steps per second") .name("Simulation steps per second")
.step(1); .step(1)
.onChange(() => saveConfig());
const controlsFolder = this.gui.addFolder("Controls"); const controlsFolder = this.gui.addFolder("Controls");
controlsFolder.add(Config, "brushRadius", 1, 100).name("Brush radius"); controlsFolder
.add(Config, "cameraZoom")
.name("Zoom")
.step(0.1)
.listen()
.onChange(() => this.emit("zoomChange"));
controlsFolder
.add(Config, "viewMode", ["side", "top"])
.name("View mode")
.listen()
.onChange(() => this.saveAndEmit("reset"));
// brush material labels map to material IDs
const brushLabels: Record<string, number> = {
"none ()": -1,
"air (R)": MAT_AIR,
"sand (1)": MAT_SAND,
"dirt (2)": MAT_DIRT,
"rock (E)": MAT_ROCK,
"food (W)": MAT_FOOD,
"home (Q)": MAT_HOME,
};
// proxy object for lil-gui string dropdown — initialize from saved config
const savedBrushLabel =
Object.entries(brushLabels).find(
([, id]) => id === Config.brushMaterial,
)?.[0] ?? "none ()";
const brushProxy = {
material: savedBrushLabel,
};
const brushFolder = this.gui.addFolder("Brush");
brushFolder
.add(brushProxy, "material", Object.keys(brushLabels))
.name("Material")
.onChange((label: string) => {
Config.brushMaterial = brushLabels[label] ?? -1;
saveConfig();
});
brushFolder
.add(Config, "brushRadius", 1, 100)
.name("Radius")
.onChange(() => saveConfig());
brushFolder.open();
simFolder.open(); simFolder.open();
controlsFolder.open(); controlsFolder.open();
const resetBtn = document.createElement("button");
resetBtn.textContent = "Reset to defaults";
resetBtn.className = "reset-btn";
resetBtn.addEventListener("click", () => {
resetConfig();
for (const c of this.gui.controllersRecursive()) {
c.updateDisplay();
}
this.emit("reset");
});
// biome-ignore lint/style/noNonNullAssertion: gui-container exists in index.html
document.getElementById("gui-container")!.appendChild(resetBtn);
} }
on(event: string, handler: EventHandler): void { on(event: string, handler: EventHandler): void {
@ -53,6 +122,11 @@ class GUIController {
this.listeners.get(event)!.push(handler); this.listeners.get(event)!.push(handler);
} }
private saveAndEmit(event: string): void {
saveConfig();
this.emit(event);
}
private emit(event: string): void { private emit(event: string): void {
const handlers = this.listeners.get(event); const handlers = this.listeners.get(event);
if (handlers) { if (handlers) {

View file

@ -1,19 +1,35 @@
import type { WebGLRenderTarget } from "three"; import type { WebGLRenderTarget } from "three";
import * as THREE from "three"; import * as THREE from "three";
import type { SceneCollection } from "./App"; import type { SceneCollection } from "./App";
import ColonyStats from "./ColonyStats"; import ColonyStats, { type ColonyStatsData } from "./ColonyStats";
import Config from "./Config"; import Config from "./Config";
import { import {
FOOD_QUALITY_MASK, MAT_AIR,
FOOD_QUALITY_SHIFT, MAT_DIRT,
TERRAIN_TYPE_MASK, MAT_FOOD,
TERRAIN_TYPE_SHIFT, MAT_HOME,
MAT_ROCK,
MAT_SAND,
} from "./constants"; } from "./constants";
import {
generateColorData,
generateLookupData,
MaterialRegistry,
} from "./materials";
import {
BEHAVIOR_GAS,
BEHAVIOR_LIQUID,
BEHAVIOR_POWDER,
BEHAVIOR_SOLID,
} from "./materials/types";
import { getBlockOffset } from "./sand/margolus";
import { generateSideViewWorld } from "./WorldInit";
interface Resources { interface Resources {
worldRenderTarget: THREE.WebGLRenderTarget; worldRenderTarget: THREE.WebGLRenderTarget;
worldRenderTargetCopy: THREE.WebGLRenderTarget; worldRenderTargetCopy: THREE.WebGLRenderTarget;
worldBlurredRenderTarget: THREE.WebGLRenderTarget; worldBlurredRenderTarget: THREE.WebGLRenderTarget;
sandPhysicsRenderTarget: THREE.WebGLRenderTarget;
antsComputeTarget0: THREE.WebGLRenderTarget; antsComputeTarget0: THREE.WebGLRenderTarget;
antsComputeTarget1: THREE.WebGLRenderTarget; antsComputeTarget1: THREE.WebGLRenderTarget;
antsDiscreteRenderTarget: THREE.WebGLRenderTarget; antsDiscreteRenderTarget: THREE.WebGLRenderTarget;
@ -23,12 +39,36 @@ interface Resources {
export default class Renderer { export default class Renderer {
private renderer: THREE.WebGLRenderer; private renderer: THREE.WebGLRenderer;
public resources!: Resources; public resources!: Resources;
private frameCounter = 0;
private colonyStats = new ColonyStats(); private colonyStats = new ColonyStats();
public readonly materialRegistry = new MaterialRegistry();
public readonly materialPropsTexture!: THREE.DataTexture;
public readonly materialColorTexture!: THREE.DataTexture;
constructor(public canvas: HTMLCanvasElement) { constructor(public canvas: HTMLCanvasElement) {
this.renderer = new THREE.WebGLRenderer({ canvas }); this.renderer = new THREE.WebGLRenderer({ canvas });
this.initResources(); this.initResources();
const propsData = generateLookupData(this.materialRegistry);
this.materialPropsTexture = new THREE.DataTexture(
propsData,
256,
1,
THREE.RGBAFormat,
THREE.FloatType,
);
this.materialPropsTexture.needsUpdate = true;
const colorData = generateColorData(this.materialRegistry);
this.materialColorTexture = new THREE.DataTexture(
colorData,
256,
1,
THREE.RGBAFormat,
THREE.FloatType,
);
this.materialColorTexture.needsUpdate = true;
} }
private initResources() { private initResources() {
@ -42,8 +82,8 @@ export default class Renderer {
format: THREE.RGBAFormat, format: THREE.RGBAFormat,
type: THREE.FloatType, type: THREE.FloatType,
depthBuffer: false, depthBuffer: false,
magFilter: THREE.LinearFilter, magFilter: THREE.NearestFilter,
minFilter: THREE.LinearFilter, minFilter: THREE.NearestFilter,
}, },
), ),
worldRenderTargetCopy: new THREE.WebGLRenderTarget( worldRenderTargetCopy: new THREE.WebGLRenderTarget(
@ -68,6 +108,17 @@ export default class Renderer {
minFilter: THREE.NearestFilter, minFilter: THREE.NearestFilter,
}, },
), ),
sandPhysicsRenderTarget: new THREE.WebGLRenderTarget(
Config.worldSize,
Config.worldSize,
{
format: THREE.RGBAFormat,
type: THREE.FloatType,
depthBuffer: false,
magFilter: THREE.NearestFilter,
minFilter: THREE.NearestFilter,
},
),
antsComputeTarget0: Renderer.makeAntsMRT(antTextureSize), antsComputeTarget0: Renderer.makeAntsMRT(antTextureSize),
antsComputeTarget1: Renderer.makeAntsMRT(antTextureSize), antsComputeTarget1: Renderer.makeAntsMRT(antTextureSize),
antsDiscreteRenderTarget: new THREE.WebGLRenderTarget( antsDiscreteRenderTarget: new THREE.WebGLRenderTarget(
@ -116,10 +167,28 @@ export default class Renderer {
const [antsComputeSource, antsComputeTarget] = const [antsComputeSource, antsComputeTarget] =
scenes.ants.getRenderTargets(); scenes.ants.getRenderTargets();
// sand physics pass
const blockOffset = getBlockOffset(this.frameCounter);
this.setViewportFromRT(this.resources.sandPhysicsRenderTarget);
this.renderer.setRenderTarget(this.resources.sandPhysicsRenderTarget);
scenes.sandPhysics.material.uniforms.uWorld.value =
this.resources.worldRenderTarget.texture;
scenes.sandPhysics.material.uniforms.uMaterialProps.value =
this.materialPropsTexture;
scenes.sandPhysics.material.uniforms.uBlockOffset.value.set(
blockOffset.x,
blockOffset.y,
);
scenes.sandPhysics.material.uniforms.uFrame.value = this.frameCounter;
this.renderer.render(scenes.sandPhysics, scenes.sandPhysics.camera);
this.frameCounter++;
this.setViewportFromRT(this.resources.worldBlurredRenderTarget); this.setViewportFromRT(this.resources.worldBlurredRenderTarget);
this.renderer.setRenderTarget(this.resources.worldBlurredRenderTarget); this.renderer.setRenderTarget(this.resources.worldBlurredRenderTarget);
scenes.worldBlur.material.uniforms.tWorld.value = scenes.worldBlur.material.uniforms.tWorld.value =
this.resources.worldRenderTarget.texture; this.resources.sandPhysicsRenderTarget.texture;
scenes.worldBlur.material.uniforms.uMaterialProps.value =
this.materialPropsTexture;
this.renderer.render(scenes.worldBlur, scenes.worldBlur.camera); this.renderer.render(scenes.worldBlur, scenes.worldBlur.camera);
this.renderer.setRenderTarget(this.resources.antsPresenceRenderTarget); this.renderer.setRenderTarget(this.resources.antsPresenceRenderTarget);
@ -135,6 +204,8 @@ export default class Renderer {
this.resources.worldBlurredRenderTarget.texture; this.resources.worldBlurredRenderTarget.texture;
scenes.ants.material.uniforms.tPresence.value = scenes.ants.material.uniforms.tPresence.value =
this.resources.antsPresenceRenderTarget.texture; this.resources.antsPresenceRenderTarget.texture;
scenes.ants.material.uniforms.uMaterialProps.value =
this.materialPropsTexture;
this.renderer.render(scenes.ants, scenes.ants.camera); this.renderer.render(scenes.ants, scenes.ants.camera);
this.setViewportFromRT(this.resources.antsDiscreteRenderTarget); this.setViewportFromRT(this.resources.antsDiscreteRenderTarget);
@ -177,7 +248,8 @@ export default class Renderer {
this.resources.worldRenderTarget.texture; this.resources.worldRenderTarget.texture;
scenes.draw.material.uniforms.pointerPosition.value = scenes.draw.material.uniforms.pointerPosition.value =
scenes.screen.pointerPosition; scenes.screen.pointerPosition;
scenes.draw.material.uniforms.drawMode.value = scenes.screen.drawMode; scenes.draw.material.uniforms.drawMode.value =
scenes.screen.effectiveDrawMode;
scenes.draw.material.uniforms.brushRadius.value = Config.brushRadius; scenes.draw.material.uniforms.brushRadius.value = Config.brushRadius;
this.renderer.render(scenes.draw, scenes.draw.camera); this.renderer.render(scenes.draw, scenes.draw.camera);
this.renderer.copyFramebufferToTexture( this.renderer.copyFramebufferToTexture(
@ -196,8 +268,7 @@ export default class Renderer {
} }
public resizeCanvas(width: number, height: number) { public resizeCanvas(width: number, height: number) {
this.canvas.width = width; this.renderer.setSize(width, height, false);
this.canvas.height = height;
} }
public getCommonMaterialDefines(): Record<string, string> { public getCommonMaterialDefines(): Record<string, string> {
@ -237,10 +308,19 @@ export default class Renderer {
REPELLENT_THRESHOLD: Renderer.convertNumberToFloatString( REPELLENT_THRESHOLD: Renderer.convertNumberToFloatString(
Config.repellentThreshold, Config.repellentThreshold,
), ),
TERRAIN_TYPE_SHIFT: String(TERRAIN_TYPE_SHIFT), MAT_AIR: String(MAT_AIR),
TERRAIN_TYPE_MASK: String(TERRAIN_TYPE_MASK), MAT_SAND: String(MAT_SAND),
FOOD_QUALITY_SHIFT: String(FOOD_QUALITY_SHIFT), MAT_DIRT: String(MAT_DIRT),
FOOD_QUALITY_MASK: String(FOOD_QUALITY_MASK), MAT_ROCK: String(MAT_ROCK),
MAT_FOOD: String(MAT_FOOD),
MAT_HOME: String(MAT_HOME),
BEHAVIOR_POWDER:
Renderer.convertNumberToFloatString(BEHAVIOR_POWDER),
BEHAVIOR_LIQUID:
Renderer.convertNumberToFloatString(BEHAVIOR_LIQUID),
BEHAVIOR_GAS: Renderer.convertNumberToFloatString(BEHAVIOR_GAS),
BEHAVIOR_SOLID: Renderer.convertNumberToFloatString(BEHAVIOR_SOLID),
VIEW_MODE_SIDE: Config.viewMode === "side" ? "1" : "0",
}; };
} }
@ -254,6 +334,23 @@ export default class Renderer {
this.renderer.setRenderTarget(this.resources.worldRenderTarget); this.renderer.setRenderTarget(this.resources.worldRenderTarget);
this.renderer.clear(); this.renderer.clear();
if (Config.viewMode === "side") {
const data = generateSideViewWorld(Config.worldSize);
const initTexture = new THREE.DataTexture(
data,
Config.worldSize,
Config.worldSize,
THREE.RGBAFormat,
THREE.FloatType,
);
initTexture.needsUpdate = true;
this.renderer.copyTextureToTexture(
initTexture,
this.resources.worldRenderTarget.texture,
);
initTexture.dispose();
}
this.resources.worldRenderTargetCopy.setSize( this.resources.worldRenderTargetCopy.setSize(
Config.worldSize, Config.worldSize,
Config.worldSize, Config.worldSize,
@ -268,6 +365,15 @@ export default class Renderer {
this.renderer.setRenderTarget(this.resources.worldBlurredRenderTarget); this.renderer.setRenderTarget(this.resources.worldBlurredRenderTarget);
this.renderer.clear(); this.renderer.clear();
this.resources.sandPhysicsRenderTarget.setSize(
Config.worldSize,
Config.worldSize,
);
this.renderer.setRenderTarget(this.resources.sandPhysicsRenderTarget);
this.renderer.clear();
this.frameCounter = 0;
this.resources.antsComputeTarget0.setSize( this.resources.antsComputeTarget0.setSize(
antTextureSize, antTextureSize,
antTextureSize, antTextureSize,
@ -301,6 +407,10 @@ export default class Renderer {
} }
} }
public get colonyStatsData(): ColonyStatsData {
return this.colonyStats.data;
}
static convertNumberToFloatString(n: number): string { static convertNumberToFloatString(n: number): string {
return n.toFixed(8); return n.toFixed(8);
} }

69
src/StatsOverlay.ts Normal file
View file

@ -0,0 +1,69 @@
import type { ColonyStatsData } from "./ColonyStats";
import Config from "./Config";
export default class StatsOverlay {
private el: HTMLElement;
private cursorEl: HTMLElement;
private tpsEl: HTMLElement;
private antsEl: HTMLElement;
private carryingEl: HTMLElement;
private tickTimestamps: number[] = [];
constructor() {
this.el = document.createElement("div");
this.el.id = "stats";
// insert after #info
// biome-ignore lint/style/noNonNullAssertion: #info exists in index.html
const info = document.getElementById("info")!;
info.after(this.el);
this.cursorEl = this.addLine("cursor");
this.tpsEl = this.addLine("tps");
this.antsEl = this.addLine("ants");
this.carryingEl = this.addLine("carrying");
}
private addLine(label: string): HTMLElement {
const line = document.createElement("div");
line.innerHTML = `<span class="label">${label}</span><span class="value">—</span>`;
this.el.appendChild(line);
// biome-ignore lint/style/noNonNullAssertion: .value span created by innerHTML above
return line.querySelector(".value")!;
}
public recordTick(): void {
const now = performance.now();
this.tickTimestamps.push(now);
// keep last 60 timestamps for averaging
if (this.tickTimestamps.length > 60) {
this.tickTimestamps.shift();
}
}
public update(
pointerPosition: { x: number; y: number },
colonyStats: ColonyStatsData,
): void {
const gridX = Math.floor(pointerPosition.x * Config.worldSize);
const gridY = Math.floor(pointerPosition.y * Config.worldSize);
this.cursorEl.textContent = `${gridX}, ${gridY}`;
// tps from tick timestamps
if (this.tickTimestamps.length >= 2) {
const span =
this.tickTimestamps[this.tickTimestamps.length - 1] -
this.tickTimestamps[0];
const tps =
span > 0 ? ((this.tickTimestamps.length - 1) / span) * 1000 : 0;
this.tpsEl.textContent = `${Math.round(tps)}`;
}
this.antsEl.textContent = `${colonyStats.totalAnts}`;
const carrying = Math.round(
colonyStats.foragerRatio * colonyStats.totalAnts,
);
this.carryingEl.textContent = `${carrying}`;
}
}

23
src/WorldInit.ts Normal file
View file

@ -0,0 +1,23 @@
import { MAT_HOME, MAT_SAND } from "./constants";
export function generateSideViewWorld(worldSize: number): Float32Array {
const data = new Float32Array(worldSize * worldSize * 4);
const sandHeight = Math.floor(worldSize * 0.6);
const surfaceRow = sandHeight - 1;
// fill bottom 60% with sand
for (let y = 0; y < sandHeight; y++) {
for (let x = 0; x < worldSize; x++) {
const idx = (y * worldSize + x) * 4;
data[idx] = MAT_SAND;
}
}
// top 40% stays MAT_AIR (Float32Array is zero-initialized)
// place home on surface near center
const centerX = Math.floor(worldSize / 2);
const homeIdx = (surfaceRow * worldSize + centerX) * 4;
data[homeIdx] = MAT_HOME;
return data;
}

View file

@ -1,66 +1,33 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
CELL_FOOD_BIT, MAT_AIR,
CELL_HOME_BIT, MAT_DIRT,
CELL_OBSTACLE_BIT, MAT_FOOD,
FOOD_QUALITY_MASK, MAT_HOME,
FOOD_QUALITY_SHIFT, MAT_ROCK,
TERRAIN_TYPE_MASK, MAT_SAND,
TERRAIN_TYPE_SHIFT,
} from "../constants"; } from "../constants";
describe("cell metadata bit layout", () => { describe("material ID constants", () => {
test("bit fields do not overlap", () => { test("IDs match registry order", () => {
// cell flags occupy bits 0-2 expect(MAT_AIR).toBe(0);
const cellFlagsBits = (1 << (CELL_OBSTACLE_BIT + 1)) - 1; expect(MAT_SAND).toBe(1);
// terrain occupies bits 3-5 expect(MAT_DIRT).toBe(2);
const terrainBits = TERRAIN_TYPE_MASK << TERRAIN_TYPE_SHIFT; expect(MAT_ROCK).toBe(3);
// food quality occupies bits 6-13 expect(MAT_FOOD).toBe(4);
const foodQualityBits = FOOD_QUALITY_MASK << FOOD_QUALITY_SHIFT; expect(MAT_HOME).toBe(5);
expect(cellFlagsBits & terrainBits).toBe(0);
expect(cellFlagsBits & foodQualityBits).toBe(0);
expect(terrainBits & foodQualityBits).toBe(0);
}); });
test("terrain type can encode 8 values", () => { test("IDs are unique", () => {
for (let t = 0; t <= 7; t++) { const ids = [MAT_AIR, MAT_SAND, MAT_DIRT, MAT_ROCK, MAT_FOOD, MAT_HOME];
const packed = t << TERRAIN_TYPE_SHIFT; expect(new Set(ids).size).toBe(ids.length);
const unpacked = (packed >> TERRAIN_TYPE_SHIFT) & TERRAIN_TYPE_MASK; });
expect(unpacked).toBe(t);
test("IDs fit in a byte", () => {
const ids = [MAT_AIR, MAT_SAND, MAT_DIRT, MAT_ROCK, MAT_FOOD, MAT_HOME];
for (const id of ids) {
expect(id).toBeGreaterThanOrEqual(0);
expect(id).toBeLessThanOrEqual(255);
} }
}); });
test("food quality can encode 0-255", () => {
for (const q of [0, 1, 127, 255]) {
const packed = q << FOOD_QUALITY_SHIFT;
const unpacked = (packed >> FOOD_QUALITY_SHIFT) & FOOD_QUALITY_MASK;
expect(unpacked).toBe(q);
}
});
test("all fields round-trip together", () => {
const food = 1;
const home = 1;
const obstacle = 0;
const terrain = 5;
const quality = 200;
const packed =
food +
(home << CELL_HOME_BIT) +
(obstacle << CELL_OBSTACLE_BIT) +
(terrain << TERRAIN_TYPE_SHIFT) +
(quality << FOOD_QUALITY_SHIFT);
expect((packed >> CELL_FOOD_BIT) & 1).toBe(food);
expect((packed >> CELL_HOME_BIT) & 1).toBe(home);
expect((packed >> CELL_OBSTACLE_BIT) & 1).toBe(obstacle);
expect((packed >> TERRAIN_TYPE_SHIFT) & TERRAIN_TYPE_MASK).toBe(
terrain,
);
expect((packed >> FOOD_QUALITY_SHIFT) & FOOD_QUALITY_MASK).toBe(
quality,
);
});
}); });

View file

@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { getBlockOffset } from "../sand/margolus";
describe("getBlockOffset", () => {
test("frame 0 returns even offset", () => {
expect(getBlockOffset(0)).toEqual({ x: 0, y: 0 });
});
test("frame 1 returns odd offset", () => {
expect(getBlockOffset(1)).toEqual({ x: 1, y: 1 });
});
test("frame 2 returns even offset", () => {
expect(getBlockOffset(2)).toEqual({ x: 0, y: 0 });
});
test("frame 3 returns odd offset", () => {
expect(getBlockOffset(3)).toEqual({ x: 1, y: 1 });
});
test("large even number returns even offset", () => {
expect(getBlockOffset(1000)).toEqual({ x: 0, y: 0 });
});
test("large odd number returns odd offset", () => {
expect(getBlockOffset(1001)).toEqual({ x: 1, y: 1 });
});
});

View file

@ -0,0 +1,121 @@
import { describe, expect, test } from "bun:test";
import {
generateColorData,
generateLookupData,
} from "../materials/lookupTexture";
import { MaterialRegistry } from "../materials/registry";
import {
BEHAVIOR_GAS,
BEHAVIOR_LIQUID,
BEHAVIOR_POWDER,
BEHAVIOR_SOLID,
type Material,
} from "../materials/types";
describe("material types", () => {
test("behavior type constants are distinct integers 0-3", () => {
const behaviors = [
BEHAVIOR_POWDER,
BEHAVIOR_LIQUID,
BEHAVIOR_GAS,
BEHAVIOR_SOLID,
];
expect(new Set(behaviors).size).toBe(4);
for (const b of behaviors) {
expect(b).toBeGreaterThanOrEqual(0);
expect(b).toBeLessThanOrEqual(3);
}
});
test("Material interface has required fields", () => {
const sand: Material = {
id: 1,
name: "sand",
behavior: BEHAVIOR_POWDER,
density: 1.5,
color: [0.76, 0.7, 0.5, 1.0],
hardness: 0.1,
angleOfRepose: 34,
};
expect(sand.behavior).toBe(BEHAVIOR_POWDER);
expect(sand.density).toBe(1.5);
});
});
describe("MaterialRegistry", () => {
test("has air at id 0", () => {
const reg = new MaterialRegistry();
const air = reg.get(0);
expect(air.name).toBe("air");
expect(air.density).toBe(0);
});
test("has sand registered", () => {
const reg = new MaterialRegistry();
const sand = reg.getByName("sand");
expect(sand).toBeDefined();
expect(sand!.behavior).toBe(BEHAVIOR_POWDER);
});
test("all material ids are unique", () => {
const reg = new MaterialRegistry();
const ids = reg.all().map((m) => m.id);
expect(new Set(ids).size).toBe(ids.length);
});
test("all material ids are in range 0-255", () => {
const reg = new MaterialRegistry();
for (const m of reg.all()) {
expect(m.id).toBeGreaterThanOrEqual(0);
expect(m.id).toBeLessThanOrEqual(255);
}
});
test("get throws for unknown id", () => {
const reg = new MaterialRegistry();
expect(() => reg.get(254)).toThrow();
});
});
describe("lookup texture generation", () => {
test("generates 256-entry property array", () => {
const reg = new MaterialRegistry();
const data = generateLookupData(reg);
expect(data.length).toBe(256 * 4);
});
test("sand properties at correct offset", () => {
const reg = new MaterialRegistry();
const data = generateLookupData(reg);
const sand = reg.getByName("sand")!;
const offset = sand.id * 4;
expect(data[offset + 0]).toBe(BEHAVIOR_POWDER);
expect(data[offset + 1]).toBeCloseTo(1.5);
expect(data[offset + 2]).toBeCloseTo(0.1);
});
test("generates 256-entry color array", () => {
const reg = new MaterialRegistry();
const data = generateColorData(reg);
expect(data.length).toBe(256 * 4);
});
test("sand color at correct offset", () => {
const reg = new MaterialRegistry();
const data = generateColorData(reg);
const sand = reg.getByName("sand")!;
const offset = sand.id * 4;
expect(data[offset + 0]).toBeCloseTo(0.76);
expect(data[offset + 1]).toBeCloseTo(0.7);
expect(data[offset + 2]).toBeCloseTo(0.5);
expect(data[offset + 3]).toBeCloseTo(1.0);
});
test("unregistered ids default to air properties", () => {
const reg = new MaterialRegistry();
const data = generateLookupData(reg);
const offset = 200 * 4;
expect(data[offset + 0]).toBe(0);
expect(data[offset + 1]).toBe(0);
});
});

View file

@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test";
import { MAT_AIR, MAT_HOME, MAT_SAND } from "../constants";
import { generateSideViewWorld } from "../WorldInit";
const SIZE = 64;
describe("generateSideViewWorld", () => {
test("output length is worldSize * worldSize * 4", () => {
const data = generateSideViewWorld(SIZE);
expect(data.length).toBe(SIZE * SIZE * 4);
});
test("bottom 60% of rows have R = MAT_SAND", () => {
const data = generateSideViewWorld(SIZE);
const sandHeight = Math.floor(SIZE * 0.6);
for (let y = 0; y < sandHeight; y++) {
for (let x = 0; x < SIZE; x++) {
const idx = (y * SIZE + x) * 4;
const mat = data[idx];
// home is allowed on the surface row
if (y === sandHeight - 1) {
expect([MAT_SAND, MAT_HOME]).toContain(mat);
} else {
expect(mat).toBe(MAT_SAND);
}
}
}
});
test("top 40% of rows have R = MAT_AIR", () => {
const data = generateSideViewWorld(SIZE);
const sandHeight = Math.floor(SIZE * 0.6);
for (let y = sandHeight; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const idx = (y * SIZE + x) * 4;
expect(data[idx]).toBe(MAT_AIR);
}
}
});
test("G, B, A channels are 0 everywhere", () => {
const data = generateSideViewWorld(SIZE);
for (let i = 0; i < SIZE * SIZE; i++) {
expect(data[i * 4 + 1]).toBe(0); // G
expect(data[i * 4 + 2]).toBe(0); // B
expect(data[i * 4 + 3]).toBe(0); // A
}
});
test("exactly one cell has R = MAT_HOME on the surface row near center X", () => {
const data = generateSideViewWorld(SIZE);
const surfaceRow = Math.floor(SIZE * 0.6) - 1;
const centerX = Math.floor(SIZE / 2);
const tolerance = Math.floor(SIZE * 0.1);
let homeCount = 0;
for (let i = 0; i < SIZE * SIZE; i++) {
if (data[i * 4] === MAT_HOME) {
homeCount++;
}
}
expect(homeCount).toBe(1);
// verify it's on the surface row — find the row
for (let x = 0; x < SIZE; x++) {
const idx = (surfaceRow * SIZE + x) * 4;
if (data[idx] === MAT_HOME) {
expect(Math.abs(x - centerX)).toBeLessThanOrEqual(tolerance);
return;
}
}
// if we reach here, home wasn't on the surface row
throw new Error("home not on surface row");
});
test("no food placed on the surface row", () => {
const data = generateSideViewWorld(SIZE);
const surfaceRow = Math.floor(SIZE * 0.6) - 1;
for (let x = 0; x < SIZE; x++) {
const idx = (surfaceRow * SIZE + x) * 4;
const mat = data[idx];
expect(mat === MAT_SAND || mat === MAT_HOME).toBe(true);
}
});
});

View file

@ -1,14 +1,9 @@
// cell metadata bit layout for world texture R channel // material IDs for world texture R channel
// must match registration order in materials/registry.ts
// bits 0-2: cell type flags export const MAT_AIR = 0;
export const CELL_FOOD_BIT = 0; export const MAT_SAND = 1;
export const CELL_HOME_BIT = 1; export const MAT_DIRT = 2;
export const CELL_OBSTACLE_BIT = 2; export const MAT_ROCK = 3;
export const MAT_FOOD = 4;
// bits 3-5: terrain type (0-7) export const MAT_HOME = 5;
export const TERRAIN_TYPE_SHIFT = 3;
export const TERRAIN_TYPE_MASK = 0b111; // 3 bits
// bits 6-13: food quality (0-255)
export const FOOD_QUALITY_SHIFT = 6;
export const FOOD_QUALITY_MASK = 0xff; // 8 bits

11
src/materials/index.ts Normal file
View file

@ -0,0 +1,11 @@
export { generateColorData, generateLookupData } from "./lookupTexture";
export { MaterialRegistry } from "./registry";
export {
BEHAVIOR_GAS,
BEHAVIOR_LIQUID,
BEHAVIOR_POWDER,
BEHAVIOR_SOLID,
type BehaviorType,
type Color4,
type Material,
} from "./types";

View file

@ -0,0 +1,28 @@
import type { MaterialRegistry } from "./registry";
// generates flat Float32 array for material properties texture (256x1 RGBA)
// R: behavior type, G: density, B: hardness, A: angleOfRepose / 90
export function generateLookupData(registry: MaterialRegistry): Float32Array {
const data = new Float32Array(256 * 4);
for (const m of registry.all()) {
const offset = m.id * 4;
data[offset + 0] = m.behavior;
data[offset + 1] = m.density;
data[offset + 2] = m.hardness;
data[offset + 3] = m.angleOfRepose / 90.0;
}
return data;
}
// generates flat Float32 array for material color texture (256x1 RGBA)
export function generateColorData(registry: MaterialRegistry): Float32Array {
const data = new Float32Array(256 * 4);
for (const m of registry.all()) {
const offset = m.id * 4;
data[offset + 0] = m.color[0];
data[offset + 1] = m.color[1];
data[offset + 2] = m.color[2];
data[offset + 3] = m.color[3];
}
return data;
}

89
src/materials/registry.ts Normal file
View file

@ -0,0 +1,89 @@
import {
BEHAVIOR_GAS,
BEHAVIOR_POWDER,
BEHAVIOR_SOLID,
type Material,
} from "./types";
const BUILTIN_MATERIALS: Material[] = [
{
id: 0,
name: "air",
behavior: BEHAVIOR_GAS,
density: 0,
color: [0, 0, 0, 0],
hardness: 0,
angleOfRepose: 0,
},
{
id: 1,
name: "sand",
behavior: BEHAVIOR_POWDER,
density: 1.5,
color: [0.76, 0.7, 0.5, 1.0],
hardness: 0.1,
angleOfRepose: 34,
},
{
id: 2,
name: "dirt",
behavior: BEHAVIOR_POWDER,
density: 1.3,
color: [0.45, 0.32, 0.18, 1.0],
hardness: 0.2,
angleOfRepose: 40,
},
{
id: 3,
name: "rock",
behavior: BEHAVIOR_SOLID,
density: 2.5,
color: [0.5, 0.5, 0.5, 1.0],
hardness: 1.0,
angleOfRepose: 90,
},
{
id: 4,
name: "food",
behavior: BEHAVIOR_SOLID,
density: 1.0,
color: [0.2, 0.8, 0.2, 1.0],
hardness: 0.0,
angleOfRepose: 0,
},
{
id: 5,
name: "home",
behavior: BEHAVIOR_SOLID,
density: 0,
color: [0.3, 0.3, 1.0, 1.0],
hardness: 0.0,
angleOfRepose: 0,
},
];
export class MaterialRegistry {
private byId: Map<number, Material> = new Map();
private byName: Map<string, Material> = new Map();
constructor() {
for (const m of BUILTIN_MATERIALS) {
this.byId.set(m.id, m);
this.byName.set(m.name, m);
}
}
get(id: number): Material {
const m = this.byId.get(id);
if (!m) throw new Error(`Unknown material id: ${id}`);
return m;
}
getByName(name: string): Material | undefined {
return this.byName.get(name);
}
all(): Material[] {
return [...this.byId.values()];
}
}

23
src/materials/types.ts Normal file
View file

@ -0,0 +1,23 @@
export const BEHAVIOR_POWDER = 0;
export const BEHAVIOR_LIQUID = 1;
export const BEHAVIOR_GAS = 2;
export const BEHAVIOR_SOLID = 3;
export type BehaviorType =
| typeof BEHAVIOR_POWDER
| typeof BEHAVIOR_LIQUID
| typeof BEHAVIOR_GAS
| typeof BEHAVIOR_SOLID;
// RGBA color, each component 0-1
export type Color4 = [number, number, number, number];
export interface Material {
id: number; // 0-255, index into lookup texture
name: string;
behavior: BehaviorType;
density: number; // relative weight, determines displacement order
color: Color4;
hardness: number; // 0-1, resistance to ant digging / degradation
angleOfRepose: number; // degrees, used in tier 2 gravity
}

4
src/sand/margolus.ts Normal file
View file

@ -0,0 +1,4 @@
export function getBlockOffset(frame: number): { x: number; y: number } {
const parity = frame & 1;
return { x: parity, y: parity };
}

View file

@ -22,6 +22,7 @@ export default class AntsComputeScene extends AbstractScene {
tLastExtState: { value: null }, tLastExtState: { value: null },
tWorld: { value: null }, tWorld: { value: null },
tPresence: { value: null }, tPresence: { value: null },
uMaterialProps: { value: null },
uForagerRatio: { value: 0 }, uForagerRatio: { value: 0 },
}, },
vertexShader, vertexShader,

View file

@ -18,7 +18,7 @@ export default class DrawScene extends AbstractScene {
uniforms: { uniforms: {
tWorld: { value: null }, tWorld: { value: null },
pointerPosition: { value: new THREE.Vector2() }, pointerPosition: { value: new THREE.Vector2() },
drawMode: { value: 0 }, drawMode: { value: -1 },
brushRadius: { value: 0 }, brushRadius: { value: 0 },
}, },
vertexShader, vertexShader,

View file

@ -0,0 +1,43 @@
import * as THREE from "three";
import type Renderer from "../Renderer";
import fragmentShader from "../shaders/sandPhysics.frag";
import vertexShader from "../shaders/sandPhysics.vert";
import FullScreenTriangleGeometry from "../utils/FullScreenTriangleGeometry";
import AbstractScene from "./AbstractScene";
export default class SandPhysicsScene extends AbstractScene {
public readonly camera: THREE.OrthographicCamera =
new THREE.OrthographicCamera();
public readonly material: THREE.RawShaderMaterial;
constructor(renderer: Renderer) {
super(renderer);
const geometry = new FullScreenTriangleGeometry();
const material = new THREE.RawShaderMaterial({
uniforms: {
uWorld: { value: null },
uMaterialProps: { value: null },
uBlockOffset: { value: new THREE.Vector2(0, 0) },
uFrame: { value: 0 },
},
vertexShader,
fragmentShader,
defines: this.renderer.getCommonMaterialDefines(),
glslVersion: THREE.GLSL3,
});
const mesh = new THREE.Mesh(geometry, material);
this.add(mesh);
this.material = material;
}
public recompileMaterials() {
this.material.defines = this.renderer.getCommonMaterialDefines();
this.material.needsUpdate = true;
}
public resize(_width: number, _height: number) {}
public update() {}
}

View file

@ -1,5 +1,13 @@
import * as THREE from "three"; import * as THREE from "three";
import Config from "../Config"; import Config from "../Config";
import {
MAT_AIR,
MAT_DIRT,
MAT_FOOD,
MAT_HOME,
MAT_ROCK,
MAT_SAND,
} from "../constants";
import type Renderer from "../Renderer"; import type Renderer from "../Renderer";
import fragmentShaderAnts from "../shaders/ants.frag"; import fragmentShaderAnts from "../shaders/ants.frag";
import vertexShaderAnts from "../shaders/ants.vert"; import vertexShaderAnts from "../shaders/ants.vert";
@ -7,23 +15,23 @@ import fragmentShaderGround from "../shaders/screenWorld.frag";
import vertexShaderGround from "../shaders/screenWorld.vert"; import vertexShaderGround from "../shaders/screenWorld.vert";
import AbstractScene from "./AbstractScene"; import AbstractScene from "./AbstractScene";
enum PointerState {
None,
Food,
Home,
Obstacle,
Erase,
}
export default class ScreenScene extends AbstractScene { export default class ScreenScene extends AbstractScene {
public readonly camera: THREE.OrthographicCamera; public readonly camera: THREE.OrthographicCamera;
public readonly material: THREE.ShaderMaterial; public readonly material: THREE.ShaderMaterial;
public ants!: THREE.InstancedMesh; public ants!: THREE.InstancedMesh;
public readonly groundMaterial: THREE.ShaderMaterial; public readonly groundMaterial: THREE.ShaderMaterial;
public readonly pointerPosition: THREE.Vector2 = new THREE.Vector2(); public readonly pointerPosition: THREE.Vector2 = new THREE.Vector2();
public drawMode: PointerState = PointerState.None; public drawMode: number = -1;
private cameraZoomLinear: number = 0; // zoom stored in Config.cameraZoom
private isPointerDown: boolean = false; private isPointerDown: boolean = false;
// resolves active draw mode: key-held takes priority, then GUI brush selection
public get effectiveDrawMode(): number {
if (this.drawMode >= 0) return this.drawMode;
if (this.isPointerDown && Config.brushMaterial >= 0)
return Config.brushMaterial;
return -1;
}
public renderWidth: number = 1; public renderWidth: number = 1;
public renderHeight: number = 1; public renderHeight: number = 1;
@ -38,6 +46,9 @@ export default class ScreenScene extends AbstractScene {
value: this.renderer.resources.worldRenderTarget value: this.renderer.resources.worldRenderTarget
.texture, .texture,
}, },
uMaterialColors: {
value: this.renderer.materialColorTexture,
},
}, },
vertexShader: vertexShaderGround, vertexShader: vertexShaderGround,
fragmentShader: fragmentShaderGround, fragmentShader: fragmentShaderGround,
@ -47,6 +58,7 @@ export default class ScreenScene extends AbstractScene {
); );
this.groundMaterial = ground.material; this.groundMaterial = ground.material;
ground.frustumCulled = false;
this.add(ground); this.add(ground);
@ -89,8 +101,9 @@ export default class ScreenScene extends AbstractScene {
this.renderer.canvas.addEventListener("pointerdown", (e) => { this.renderer.canvas.addEventListener("pointerdown", (e) => {
this.isPointerDown = true; this.isPointerDown = true;
raycastVector.x = (e.clientX / window.innerWidth) * 2 - 1; const rect = this.renderer.canvas.getBoundingClientRect();
raycastVector.y = -(e.clientY / window.innerHeight) * 2 + 1; raycastVector.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
raycastVector.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(raycastVector, this.camera); raycaster.setFromCamera(raycastVector, this.camera);
@ -104,17 +117,18 @@ export default class ScreenScene extends AbstractScene {
this.renderer.canvas.addEventListener("pointermove", (e) => { this.renderer.canvas.addEventListener("pointermove", (e) => {
if (this.isPointerDown) { if (this.isPointerDown) {
const rect = this.renderer.canvas.getBoundingClientRect();
const dx = e.movementX; const dx = e.movementX;
const dy = e.movementY; const dy = e.movementY;
this.camera.position.x -= this.camera.position.x -= dx / rect.height / this.camera.zoom;
dx / window.innerHeight / this.camera.zoom; this.camera.position.y += dy / rect.height / this.camera.zoom;
this.camera.position.y += this.clampCamera();
dy / window.innerHeight / this.camera.zoom;
} }
raycastVector.x = (e.clientX / window.innerWidth) * 2 - 1; const rect = this.renderer.canvas.getBoundingClientRect();
raycastVector.y = -(e.clientY / window.innerHeight) * 2 + 1; raycastVector.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
raycastVector.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(raycastVector, this.camera); raycaster.setFromCamera(raycastVector, this.camera);
@ -135,42 +149,84 @@ export default class ScreenScene extends AbstractScene {
}); });
this.renderer.canvas.addEventListener("wheel", (e) => { this.renderer.canvas.addEventListener("wheel", (e) => {
this.cameraZoomLinear -= e.deltaY * 0.001; Config.cameraZoom -= e.deltaY * 0.001;
Config.cameraZoom = Math.max(-1, Config.cameraZoom);
this.updateCameraZoom(); this.updateCameraZoom();
this.clampCamera();
}); });
window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => {
switch (e.code) { switch (e.code) {
case "KeyQ": { case "KeyQ": {
this.drawMode = PointerState.Home; this.drawMode = MAT_HOME;
break; break;
} }
case "KeyW": { case "KeyW": {
this.drawMode = PointerState.Food; this.drawMode = MAT_FOOD;
break; break;
} }
case "KeyE": { case "KeyE": {
this.drawMode = PointerState.Obstacle; this.drawMode = MAT_ROCK;
break; break;
} }
case "KeyR": { case "KeyR": {
this.drawMode = PointerState.Erase; this.drawMode = MAT_AIR;
break;
}
case "Digit1": {
this.drawMode = MAT_SAND;
break;
}
case "Digit2": {
this.drawMode = MAT_DIRT;
break;
}
case "KeyV": {
Config.viewMode =
Config.viewMode === "side" ? "top" : "side";
window.dispatchEvent(new CustomEvent("viewModeToggle"));
break; break;
} }
} }
}); });
window.addEventListener("keyup", () => { window.addEventListener("keyup", () => {
this.drawMode = PointerState.None; this.drawMode = -1;
}); });
} }
private updateCameraZoom() { private updateCameraZoom() {
this.camera.zoom = 2 ** this.cameraZoomLinear; this.camera.zoom = 2 ** Config.cameraZoom;
this.camera.updateProjectionMatrix(); this.camera.updateProjectionMatrix();
} }
private clampCamera() {
// world plane spans from -0.5 to 0.5 in both axes
// visible half-extents from camera center
const halfW =
(this.camera.right - this.camera.left) / 2 / this.camera.zoom;
const halfH =
(this.camera.top - this.camera.bottom) / 2 / this.camera.zoom;
// camera center can go at most to world edge + half viewport,
// but never so far that the world leaves the screen entirely.
// when zoomed out (halfW > 0.5), the world fits in view — lock to center.
const minX = halfW >= 0.5 ? 0 : -0.5 + halfW;
const maxX = halfW >= 0.5 ? 0 : 0.5 - halfW;
const minY = halfH >= 0.5 ? 0 : -0.5 + halfH;
const maxY = halfH >= 0.5 ? 0 : 0.5 - halfH;
this.camera.position.x = Math.max(
minX,
Math.min(maxX, this.camera.position.x),
);
this.camera.position.y = Math.max(
minY,
Math.min(maxY, this.camera.position.y),
);
}
private createInstancedAntsMesh() { private createInstancedAntsMesh() {
if (this.ants) { if (this.ants) {
this.remove(this.ants); this.remove(this.ants);
@ -187,6 +243,7 @@ export default class ScreenScene extends AbstractScene {
); );
ants.position.x = ants.position.y = -0.5; ants.position.x = ants.position.y = -0.5;
ants.frustumCulled = false;
this.add(ants); this.add(ants);
@ -214,4 +271,9 @@ export default class ScreenScene extends AbstractScene {
} }
public update() {} public update() {}
public applyCameraZoom() {
this.updateCameraZoom();
this.clampCamera();
}
} }

View file

@ -17,6 +17,7 @@ export default class WorldBlurScene extends AbstractScene {
const material = new THREE.RawShaderMaterial({ const material = new THREE.RawShaderMaterial({
uniforms: { uniforms: {
tWorld: { value: null }, tWorld: { value: null },
uMaterialProps: { value: null },
}, },
vertexShader, vertexShader,
fragmentShader, fragmentShader,

View file

@ -14,7 +14,9 @@ uniform sampler2D tLastExtState;
uniform sampler2D tWorld; uniform sampler2D tWorld;
uniform sampler2D tPresence; uniform sampler2D tPresence;
uniform float uForagerRatio; uniform float uForagerRatio;
uniform sampler2D uMaterialProps;
const float ANT_CARRY_STRENGTH = 1.0;
const float sampleDistance = 20.; const float sampleDistance = 20.;
const float cellSize = 1. / WORLD_SIZE; const float cellSize = 1. / WORLD_SIZE;
@ -32,21 +34,21 @@ vec2 roundUvToCellCenter(vec2 uv) {
} }
bool tryGetFood(vec2 pos) { bool tryGetFood(vec2 pos) {
float value = texture(tWorld, roundUvToCellCenter(pos)).x; float materialId = texture(tWorld, roundUvToCellCenter(pos)).x;
return (int(value) & 1) == 1; return int(materialId) == MAT_FOOD;
} }
bool tryDropFood(vec2 pos) { bool tryDropFood(vec2 pos) {
float value = texture(tWorld, roundUvToCellCenter(pos)).x; float materialId = texture(tWorld, roundUvToCellCenter(pos)).x;
return ((int(value) & 2) >> 1) == 1; return int(materialId) == MAT_HOME;
} }
bool isObstacle(vec2 pos) { bool isObstacle(vec2 pos) {
float value = texture(tWorld, roundUvToCellCenter(pos)).x; float materialId = texture(tWorld, roundUvToCellCenter(pos)).x;
return ((int(value) & 4) >> 2) == 1; return int(materialId) == MAT_ROCK;
} }
float smell(vec2 pos, float isCarrying) { float smell(vec2 pos, float isCarrying) {
@ -88,19 +90,35 @@ void main() {
bool wasObstacle = isObstacle(pos); bool wasObstacle = isObstacle(pos);
float personality = lastExtState.r; float personality = lastExtState.r;
float cargoQuality = lastExtState.g; float cargoMaterialId = lastExtState.g;
float pathIntDx = lastExtState.b; float pathIntDx = lastExtState.b;
float pathIntDy = lastExtState.a; float pathIntDy = lastExtState.a;
bool movementProcessed = false; bool movementProcessed = false;
if (pos == vec2(0)) { // init new ant if (pos == vec2(0)) { // init new ant
#if VIEW_MODE_SIDE
// spawn on sand surface: random X, scan down from top to find first non-air cell
float spawnX = rand(vUv * 10000.);
float pixelSize = 1.0 / float(textureSize(tWorld, 0).x);
float surfaceY = 0.6; // fallback if scan finds nothing
for (float scanY = 1.0; scanY > 0.0; scanY -= pixelSize) {
float matId = texture(tWorld, vec2(spawnX, scanY)).x;
if (matId > 0.5) { // non-air cell found
surfaceY = scanY + pixelSize; // one cell above the surface
break;
}
}
pos = vec2(spawnX, surfaceY);
angle = -PI * 0.5; // face downward initially
#else
pos = vec2(0.5); pos = vec2(0.5);
angle = rand(vUv * 10000.) * 2. * PI; angle = rand(vUv * 10000.) * 2. * PI;
#endif
isCarrying = 0.; isCarrying = 0.;
storage = 0.; storage = 0.;
personality = rand(vUv * 42069.); // 0.0 = pure follower, 1.0 = pure explorer personality = rand(vUv * 42069.); // 0.0 = pure follower, 1.0 = pure explorer
cargoQuality = 0.; cargoMaterialId = 0.;
pathIntDx = 0.; pathIntDx = 0.;
pathIntDy = 0.; pathIntDy = 0.;
} }
@ -162,6 +180,38 @@ void main() {
} }
if (!movementProcessed) { if (!movementProcessed) {
#if VIEW_MODE_SIDE
// vertical bias for digging behavior
if (isCarrying == 1. && int(cargoMaterialId) != MAT_FOOD) {
// carrying powder: bias upward toward surface
float upwardBias = PI * 0.5; // straight up
float angleDiff = upwardBias - angle;
// normalize to [-PI, PI]
angleDiff = mod(angleDiff + PI, 2.0 * PI) - PI;
// gentle steering toward up (blend 30% toward upward)
angle += angleDiff * 0.3;
} else if (isCarrying == 0.) {
// not carrying: check if surrounded by diggable material
// if so, prefer downward movement
float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
vec4 props = texelFetch(uMaterialProps, ivec2(int(cellMatId), 0), 0);
// check cell below for diggable material
vec2 belowUv = roundUvToCellCenter(pos - vec2(0., cellSize));
float belowMat = texture(tWorld, belowUv).x;
vec4 belowProps = texelFetch(uMaterialProps, ivec2(int(belowMat), 0), 0);
if (belowProps.r == BEHAVIOR_POWDER && belowProps.b <= ANT_CARRY_STRENGTH) {
// diggable material below — bias downward
float downwardBias = -PI * 0.5; // straight down
float angleDiff = downwardBias - angle;
angleDiff = mod(angleDiff + PI, 2.0 * PI) - PI;
// gentle steering toward down (20% blend)
angle += angleDiff * 0.2;
}
}
#endif
float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2); float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2);
float sampleAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), isCarrying); float sampleAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), isCarrying);
@ -194,10 +244,28 @@ void main() {
angle += PI * (noise - 0.5); angle += PI * (noise - 0.5);
} }
if (tryGetFood(pos) && isCarrying == 0.) { if (isCarrying == 0.) {
float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
int cellMatInt = int(cellMatId);
if (cellMatInt == MAT_FOOD) {
// food pickup (existing foraging behavior)
isCarrying = 1.; isCarrying = 1.;
cargoMaterialId = cellMatId;
angle += PI; angle += PI;
storage = getMaxScentStorage(vUv); storage = getMaxScentStorage(vUv);
} else if (cellMatInt != MAT_AIR && cellMatInt != MAT_HOME) {
// check if diggable powder material
vec4 props = texelFetch(uMaterialProps, ivec2(cellMatInt, 0), 0);
float behavior = props.r;
float hardness = props.b;
if (behavior == BEHAVIOR_POWDER && hardness <= ANT_CARRY_STRENGTH) {
isCarrying = 1.;
cargoMaterialId = cellMatId;
angle += PI;
storage = getMaxScentStorage(vUv);
}
}
} }
if (tryDropFood(pos)) { if (tryDropFood(pos)) {
@ -205,15 +273,31 @@ void main() {
if (isCarrying == 1.) { if (isCarrying == 1.) {
isCarrying = 0.; isCarrying = 0.;
cargoMaterialId = 0.;
angle += PI; angle += PI;
} }
} }
// deposit carried powder material when standing on air with solid ground below
if (isCarrying == 1. && int(cargoMaterialId) != MAT_FOOD) {
float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
if (int(cellMatId) == MAT_AIR) {
vec2 belowPos = pos - vec2(0., cellSize);
float belowMatId = texture(tWorld, roundUvToCellCenter(belowPos)).x;
if (int(belowMatId) != MAT_AIR || belowPos.y <= 0.) {
isCarrying = 0.;
// keep cargoMaterialId set so discretize can read it this frame
angle += PI;
storage = getMaxScentStorage(vUv);
}
}
}
FragColor = vec4( FragColor = vec4(
pos.x, pos.x,
pos.y, pos.y,
angle, angle,
float((uint(max(storage - SCENT_PER_MARKER, 0.)) << 1) + uint(isCarrying)) float((uint(max(storage - SCENT_PER_MARKER, 0.)) << 1) + uint(isCarrying))
); );
FragColorExt = vec4(personality, cargoQuality, pathIntDx, pathIntDy); FragColorExt = vec4(personality, cargoMaterialId, pathIntDx, pathIntDy);
} }

View file

@ -5,9 +5,11 @@ in vec2 vUv;
in float vIsCarryingFood; in float vIsCarryingFood;
in float vScentFactor; in float vScentFactor;
in float vIsCellCleared; in float vIsCellCleared;
in float vDepositMaterialId;
out vec4 FragColor; out vec4 FragColor;
void main() { void main() {
FragColor = vec4(vIsCarryingFood * vScentFactor, (1. - vIsCarryingFood) * vScentFactor, vIsCellCleared, 1); // encode deposit material ID in alpha: divide by 255 to fit in UnsignedByte channel
FragColor = vec4(vIsCarryingFood * vScentFactor, (1. - vIsCarryingFood) * vScentFactor, vIsCellCleared, vDepositMaterialId / 255.);
} }

View file

@ -8,6 +8,7 @@ out vec2 vUv;
out float vIsCarryingFood; out float vIsCarryingFood;
out float vScentFactor; out float vScentFactor;
out float vIsCellCleared; out float vIsCellCleared;
out float vDepositMaterialId;
uniform sampler2D tDataCurrent; uniform sampler2D tDataCurrent;
uniform sampler2D tDataLast; uniform sampler2D tDataLast;
@ -38,6 +39,13 @@ void main() {
vScentFactor = storage / SCENT_MAX_STORAGE; vScentFactor = storage / SCENT_MAX_STORAGE;
vIsCellCleared = isCellCleared; vIsCellCleared = isCellCleared;
// detect deposit: was carrying, now not — read cargo ID from extended state
float isDepositing = float(wasCarrying == 1. && isCarrying == 0.);
vec4 extSample = texture(tDataExtCurrent, vec2(sampleX, sampleY) / dataTextureSize);
// cargoMaterialId is kept non-zero on the deposit frame (zeroed next frame for food drops,
// and stays as-is for powder deposits until the ant picks something else up)
vDepositMaterialId = isDepositing * extSample.g;
gl_Position = vec4( gl_Position = vec4(
(position.xy * cellSize + floor(offset * WORLD_SIZE) / WORLD_SIZE + cellSize * 0.5) * 2. - 1., (position.xy * cellSize + floor(offset * WORLD_SIZE) / WORLD_SIZE + cellSize * 0.5) * 2. - 1.,
0, 0,

View file

@ -13,24 +13,12 @@ uniform float brushRadius;
void main() { void main() {
vec4 lastState = texture(tWorld, vUv); vec4 lastState = texture(tWorld, vUv);
int cellData = int(lastState.x); float materialId = lastState.x;
int isFood = cellData & 1;
int isHome = (cellData & 2) >> 1;
int isObstacle = (cellData & 4) >> 2;
if (distance(pointerPosition, vUv) < brushRadius / WORLD_SIZE) { // drawMode >= 0 means painting that material ID; -1 means not drawing
if (drawMode == 1.) { if (drawMode >= 0. && distance(pointerPosition, vUv) < brushRadius / WORLD_SIZE) {
isFood = 1; materialId = drawMode;
} else if (drawMode == 2.) {
isHome = 1;
} else if (drawMode == 3.) {
isObstacle = 1;
} else if (drawMode == 4.) {
isFood = 0;
isHome = 0;
isObstacle = 0;
}
} }
FragColor = vec4(float(isFood + (isHome << 1) + (isObstacle << 2)), lastState.yzw); FragColor = vec4(materialId, lastState.yzw);
} }

View file

@ -0,0 +1,112 @@
precision highp float;
precision highp int;
in vec2 vUv;
uniform sampler2D uWorld;
uniform sampler2D uMaterialProps;
uniform vec2 uBlockOffset;
uniform int uFrame;
out vec4 fragColor;
uint hash(uint x) {
x ^= x >> 16u;
x *= 0x45d9f3bu;
x ^= x >> 16u;
return x;
}
void main() {
ivec2 pixel = ivec2(gl_FragCoord.xy);
ivec2 blockBase = ((pixel - ivec2(uBlockOffset)) / 2) * 2 + ivec2(uBlockOffset);
ivec2 localPos = pixel - blockBase;
int localIndex = localPos.x + localPos.y * 2;
// bounds check
int worldSize = int(WORLD_SIZE);
if (blockBase.x < 0 || blockBase.y < 0 ||
blockBase.x + 1 >= worldSize || blockBase.y + 1 >= worldSize) {
fragColor = texelFetch(uWorld, pixel, 0);
return;
}
// read 2x2 block
// index mapping (GL coords — Y increases upward):
// [2][3] <- top row (y offset 1, higher Y)
// [0][1] <- bottom row (y offset 0, lower Y)
vec4 cells[4];
float behaviors[4];
float densities[4];
for (int i = 0; i < 4; i++) {
ivec2 p = blockBase + ivec2(i % 2, i / 2);
cells[i] = texelFetch(uWorld, p, 0);
vec4 props = texelFetch(uMaterialProps, ivec2(int(cells[i].r), 0), 0);
behaviors[i] = props.r;
densities[i] = props.g;
}
// capture blocked state before vertical swaps modify the arrays
// "blocked" = column is settled (top lighter/equal to bottom) or contains solid
bool col0Blocked = (behaviors[0] == BEHAVIOR_SOLID || behaviors[2] == BEHAVIOR_SOLID || densities[2] <= densities[0]);
bool col1Blocked = (behaviors[1] == BEHAVIOR_SOLID || behaviors[3] == BEHAVIOR_SOLID || densities[3] <= densities[1]);
// random seed for this block this frame
uint seed = hash(uint(blockBase.x) * 7919u + uint(blockBase.y) * 6271u + uint(uFrame) * 3571u);
// vertical gravity: top cells (2,3) fall to bottom (0,1) if heavier
for (int col = 0; col < 2; col++) {
int top = col + 2; // 2 or 3 — top row (higher Y)
int bot = col; // 0 or 1 — bottom row (lower Y)
if (behaviors[top] != BEHAVIOR_SOLID && behaviors[bot] != BEHAVIOR_SOLID) {
if (densities[top] > densities[bot]) {
// swap entire vec4
vec4 tmp = cells[top];
cells[top] = cells[bot];
cells[bot] = tmp;
// update cached props
float tmpB = behaviors[top]; behaviors[top] = behaviors[bot]; behaviors[bot] = tmpB;
float tmpD = densities[top]; densities[top] = densities[bot]; densities[bot] = tmpD;
}
}
}
// diagonal: if a top cell couldn't fall straight down, try diagonal
// top-left (2) -> bottom-right (1), top-right (3) -> bottom-left (0)
bool tryLeftFirst = (seed & 1u) == 0u;
if (tryLeftFirst) {
// try 2->1 diagonal (top-left to bottom-right)
if (col0Blocked && behaviors[2] != BEHAVIOR_SOLID && behaviors[1] != BEHAVIOR_SOLID &&
densities[2] > densities[1]) {
vec4 tmp = cells[2]; cells[2] = cells[1]; cells[1] = tmp;
float tmpB = behaviors[2]; behaviors[2] = behaviors[1]; behaviors[1] = tmpB;
float tmpD = densities[2]; densities[2] = densities[1]; densities[1] = tmpD;
}
// try 3->0 diagonal (top-right to bottom-left)
if (col1Blocked && behaviors[3] != BEHAVIOR_SOLID && behaviors[0] != BEHAVIOR_SOLID &&
densities[3] > densities[0]) {
vec4 tmp = cells[3]; cells[3] = cells[0]; cells[0] = tmp;
float tmpB = behaviors[3]; behaviors[3] = behaviors[0]; behaviors[0] = tmpB;
float tmpD = densities[3]; densities[3] = densities[0]; densities[0] = tmpD;
}
} else {
// try 3->0 first
if (col1Blocked && behaviors[3] != BEHAVIOR_SOLID && behaviors[0] != BEHAVIOR_SOLID &&
densities[3] > densities[0]) {
vec4 tmp = cells[3]; cells[3] = cells[0]; cells[0] = tmp;
float tmpB = behaviors[3]; behaviors[3] = behaviors[0]; behaviors[0] = tmpB;
float tmpD = densities[3]; densities[3] = densities[0]; densities[0] = tmpD;
}
// try 2->1
if (col0Blocked && behaviors[2] != BEHAVIOR_SOLID && behaviors[1] != BEHAVIOR_SOLID &&
densities[2] > densities[1]) {
vec4 tmp = cells[2]; cells[2] = cells[1]; cells[1] = tmp;
float tmpB = behaviors[2]; behaviors[2] = behaviors[1]; behaviors[1] = tmpB;
float tmpD = densities[2]; densities[2] = densities[1]; densities[1] = tmpD;
}
}
fragColor = cells[localIndex];
}

View file

@ -0,0 +1,12 @@
precision highp float;
precision highp int;
in vec3 position;
in vec2 uv;
out vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}

View file

@ -6,20 +6,18 @@ in vec2 vUv;
out vec4 FragColor; out vec4 FragColor;
uniform sampler2D map; uniform sampler2D map;
uniform sampler2D uMaterialColors;
void main() { void main() {
vec4 value = texture(map, vUv); vec4 value = texture(map, vUv);
int cellData = int(value.x); int materialId = int(value.x);
int isFood = cellData & 1;
int isHome = (cellData & 2) >> 1;
int isObstacle = (cellData & 4) >> 2;
float toFood = clamp(value.y, 0., 1.); float toFood = clamp(value.y, 0., 1.);
float toHome = clamp(value.z, 0., 1.); float toHome = clamp(value.z, 0., 1.);
// pheromone overlay
// The part below doen't seem right. // The part below doen't seem right.
// I could figure out a better way to make pheromone colors blend properly on white background :( // I could figure out a better way to make pheromone colors blend properly on white background :(
vec3 t = vec3(0.95, 0.2, 0.2) * toFood + vec3(0.2, 0.2, 0.95) * toHome; vec3 t = vec3(0.95, 0.2, 0.2) * toFood + vec3(0.2, 0.2, 0.95) * toHome;
float a = clamp(toHome + toFood, 0., 1.); float a = clamp(toHome + toFood, 0., 1.);
@ -29,12 +27,10 @@ void main() {
vec3 color = mix(vec3(1, 1, 1), t, a * 0.7); vec3 color = mix(vec3(1, 1, 1), t, a * 0.7);
if (isFood == 1) { // non-air cells use material color as base, with pheromone tint
color = vec3(1, 0.1, 0.1); if (materialId != MAT_AIR) {
} else if (isHome == 1) { vec4 matColor = texelFetch(uMaterialColors, ivec2(materialId, 0), 0);
color = vec3(0.1, 0.1, 1); color = mix(matColor.rgb, t, a * 0.3);
} else if (isObstacle == 1) {
color = vec3(0.6, 0.6, 0.6);
} }
FragColor = vec4(color, 1); FragColor = vec4(color, 1);

View file

@ -12,25 +12,21 @@ void main() {
vec4 lastState = texture(tLastState, vUv); vec4 lastState = texture(tLastState, vUv);
vec4 discreteAnts = texture(tDiscreteAnts, vUv); vec4 discreteAnts = texture(tDiscreteAnts, vUv);
int cellData = int(lastState.x); float materialId = lastState.x;
int isFood = cellData & 1;
int isHome = (cellData & 2) >> 1;
int isObstacle = (cellData & 4) >> 2;
float scentToHome = min(SCENT_MAX_PER_CELL, lastState.y + discreteAnts.x); float scentToHome = min(SCENT_MAX_PER_CELL, lastState.y + discreteAnts.x);
float scentToFood = min(SCENT_MAX_PER_CELL, lastState.z + discreteAnts.y); float scentToFood = min(SCENT_MAX_PER_CELL, lastState.z + discreteAnts.y);
float repellent = min(REPELLENT_MAX_PER_CELL, lastState.w); float repellent = min(REPELLENT_MAX_PER_CELL, lastState.w);
// ant picked up food from this cell
if (discreteAnts.z == 1.) { if (discreteAnts.z == 1.) {
isFood = 0; materialId = float(MAT_AIR);
} }
int terrainType = (cellData >> TERRAIN_TYPE_SHIFT) & TERRAIN_TYPE_MASK; // ant deposited material at this cell (only into air cells)
int foodQuality = (cellData >> FOOD_QUALITY_SHIFT) & FOOD_QUALITY_MASK; int depositMatId = int(round(discreteAnts.w * 255.));
if (depositMatId > 0 && int(materialId) == MAT_AIR) {
// reconstruct cell data preserving all bit fields materialId = float(depositMatId);
int newCellData = isFood + (isHome << 1) + (isObstacle << 2) }
+ (terrainType << TERRAIN_TYPE_SHIFT)
+ (foodQuality << FOOD_QUALITY_SHIFT); FragColor = vec4(materialId, scentToHome, scentToFood, repellent);
FragColor = vec4(float(newCellData), scentToHome, scentToFood, repellent);
} }

View file

@ -6,19 +6,33 @@ in vec2 vUv;
out vec4 FragColor; out vec4 FragColor;
uniform sampler2D tWorld; uniform sampler2D tWorld;
uniform sampler2D uMaterialProps;
const float offset = 1. / WORLD_SIZE * SCENT_BLUR_RADIUS; const float offset = 1. / WORLD_SIZE * SCENT_BLUR_RADIUS;
const float repellentOffset = 1. / WORLD_SIZE * REPELLENT_BLUR_RADIUS; const float repellentOffset = 1. / WORLD_SIZE * REPELLENT_BLUR_RADIUS;
// returns 1.0 if materialId has GAS behavior (pheromone can diffuse through it)
float isAir(float materialId) {
vec4 props = texelFetch(uMaterialProps, ivec2(int(materialId), 0), 0);
return props.r == BEHAVIOR_GAS ? 1.0 : 0.0;
}
void main() { void main() {
vec4 s0 = texture(tWorld, vUv); vec4 s0 = texture(tWorld, vUv);
float centerAir = isAir(s0.x);
vec4 s1 = texture(tWorld, vUv + vec2(1, 1) * offset); vec4 s1 = texture(tWorld, vUv + vec2(1, 1) * offset);
vec4 s2 = texture(tWorld, vUv + vec2(-1, -1) * offset); vec4 s2 = texture(tWorld, vUv + vec2(-1, -1) * offset);
vec4 s3 = texture(tWorld, vUv + vec2(-1, 1) * offset); vec4 s3 = texture(tWorld, vUv + vec2(-1, 1) * offset);
vec4 s4 = texture(tWorld, vUv + vec2(1, -1) * offset); vec4 s4 = texture(tWorld, vUv + vec2(1, -1) * offset);
float scentToHome = (s0.y + s1.y + s2.y + s3.y + s4.y) / 5. * (1. - SCENT_FADE_OUT_FACTOR); float a1 = isAir(s1.x);
float scentToFood = (s0.z + s1.z + s2.z + s3.z + s4.z) / 5. * (1. - SCENT_FADE_OUT_FACTOR); float a2 = isAir(s2.x);
float a3 = isAir(s3.x);
float a4 = isAir(s4.x);
float scentCount = 1.0 + a1 + a2 + a3 + a4;
float scentToHome = (s0.y + s1.y * a1 + s2.y * a2 + s3.y * a3 + s4.y * a4) / scentCount * (1. - SCENT_FADE_OUT_FACTOR) * centerAir;
float scentToFood = (s0.z + s1.z * a1 + s2.z * a2 + s3.z * a3 + s4.z * a4) / scentCount * (1. - SCENT_FADE_OUT_FACTOR) * centerAir;
// repellent channel uses its own diffusion radius and decay rate // repellent channel uses its own diffusion radius and decay rate
vec4 rr0 = texture(tWorld, vUv); vec4 rr0 = texture(tWorld, vUv);
@ -26,7 +40,14 @@ void main() {
vec4 rr2 = texture(tWorld, vUv + vec2(-1, -1) * repellentOffset); vec4 rr2 = texture(tWorld, vUv + vec2(-1, -1) * repellentOffset);
vec4 rr3 = texture(tWorld, vUv + vec2(-1, 1) * repellentOffset); vec4 rr3 = texture(tWorld, vUv + vec2(-1, 1) * repellentOffset);
vec4 rr4 = texture(tWorld, vUv + vec2(1, -1) * repellentOffset); vec4 rr4 = texture(tWorld, vUv + vec2(1, -1) * repellentOffset);
float repellent = (rr0.w + rr1.w + rr2.w + rr3.w + rr4.w) / 5. * (1. - REPELLENT_FADE_OUT_FACTOR);
float b1 = isAir(rr1.x);
float b2 = isAir(rr2.x);
float b3 = isAir(rr3.x);
float b4 = isAir(rr4.x);
float repellentCount = 1.0 + b1 + b2 + b3 + b4;
float repellent = (rr0.w + rr1.w * b1 + rr2.w * b2 + rr3.w * b3 + rr4.w * b4) / repellentCount * (1. - REPELLENT_FADE_OUT_FACTOR) * centerAir;
FragColor = vec4( FragColor = vec4(
s0.x, s0.x,