Compare commits
42 commits
infra-simu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cd4f10bc80 | |||
| fe537218a3 | |||
| 2269f26f9f | |||
| 685a382d4d | |||
| 3e13b2ecd2 | |||
| 9f958089c0 | |||
| d91b5a9b5e | |||
| 6b514f338c | |||
| 290d27d85f | |||
| 489f121064 | |||
| 34bd1e95c2 | |||
| 43d3e56aee | |||
| 94b0393abb | |||
| cb43ebfe83 | |||
| 37d615e87a | |||
| 7003e074c5 | |||
| 8909dc6390 | |||
| cd8a4ade82 | |||
| e89ee1afa2 | |||
| e8e5691d83 | |||
| 568bfe83e3 | |||
| bf34de9816 | |||
| 66d5f6b251 | |||
| 9e5af09476 | |||
| 3787dbbc3a | |||
| 89f963f9a6 | |||
| 29e5dbeb06 | |||
| e6af97f402 | |||
| e210ebc72d | |||
| 0f9c1b47f2 | |||
| f5b04f08c6 | |||
| a1e164454d | |||
| bc2c8fa270 | |||
| dd27634f0c | |||
| 4457368636 | |||
| de010adf44 | |||
| 1c74aabd53 | |||
| a187ccded2 | |||
| 8ad5130466 | |||
| 14208e17fb | |||
| 8dfc6f54bc | |||
| eddef83e5b |
38 changed files with 4079 additions and 215 deletions
62
CLAUDE.md
62
CLAUDE.md
|
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -24,23 +24,24 @@ all simulation logic runs on the GPU via ping-pong render targets. no JS-side si
|
|||
|
||||
### render pipeline (per frame)
|
||||
|
||||
1. `WorldBlurScene` — diffuse + decay pheromones (3 channels: toHome, toFood, repellent, each with independent blur radius and decay rate)
|
||||
2. clear `antsPresenceRenderTarget` (ant-ant spatial queries, stub)
|
||||
3. `AntsComputeScene` — per-ant state via MRT (writes 2 textures simultaneously)
|
||||
4. `AntsDiscretizeScene` — maps continuous ant positions to discrete world grid cells
|
||||
5. `WorldComputeScene` — merges ant deposits into world pheromone grid
|
||||
6. `ColonyStats` — CPU readback of ant texture, computes aggregate stats (foragerRatio), feeds back as uniforms
|
||||
7. `DrawScene` — user painting (food, home, obstacles, erase)
|
||||
8. `ScreenScene` — final composited output with camera controls
|
||||
1. `SandPhysicsScene` — Margolus block CA for sand/powder physics
|
||||
2. `WorldBlurScene` — diffuse + decay pheromones (3 channels: toHome, toFood, repellent, blocked by solid cells)
|
||||
3. clear `antsPresenceRenderTarget` (ant-ant spatial queries, stub)
|
||||
4. `AntsComputeScene` — per-ant state via MRT (writes 2 textures simultaneously), material-aware digging
|
||||
5. `AntsDiscretizeScene` — maps continuous ant positions to discrete world grid cells
|
||||
6. `WorldComputeScene` — merges ant deposits into world pheromone grid
|
||||
7. `ColonyStats` — CPU readback of ant texture, computes aggregate stats (foragerRatio), feeds back as uniforms
|
||||
8. `DrawScene` — user painting with material palette
|
||||
9. `ScreenScene` — final composited output with side/top camera views (V to toggle)
|
||||
|
||||
### GPU textures
|
||||
|
||||
**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 1: `[personality, cargoQuality, pathIntDx, pathIntDy]`
|
||||
- texture 1: `[personality, cargoMaterialId, pathIntDx, pathIntDy]`
|
||||
|
||||
**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
|
||||
- B: scentToFood
|
||||
- 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.
|
||||
|
||||
### bit layout (world.R)
|
||||
### material system
|
||||
|
||||
defined in `src/constants.ts`, shared between TS and GLSL via defines:
|
||||
- bits 0-2: cell flags (food, home, obstacle)
|
||||
- bits 3-5: terrain type (0-7, reserved for substrate-dependent decay)
|
||||
- bits 6-13: food quality (0-255, reserved for quality-dependent pheromone modulation)
|
||||
`src/materials/` defines a data-driven material registry. adding a new material requires only a registry entry — no shader changes needed.
|
||||
|
||||
- `MaterialRegistry` — 6 built-in materials (ids 0-5): air, sand, dirt, rock, food, home
|
||||
- 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
|
||||
|
||||
- `src/Renderer.ts` — render target creation, pass orchestration, MRT setup, colony stats readback
|
||||
- `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/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/worldBlur.frag` — per-channel pheromone diffusion/decay
|
||||
- `src/shaders/world.frag` — cell metadata bit preservation + pheromone merging
|
||||
- `src/shaders/worldBlur.frag` — per-channel pheromone diffusion/decay (solid cells block diffusion)
|
||||
- `src/shaders/world.frag` — material ID preservation + pheromone merging
|
||||
- `src/shaders/sandPhysics.frag` — Margolus block CA for powder/sand movement
|
||||
|
||||
## planning docs
|
||||
|
||||
- `REALISM-IDEAS.md` — research-backed features for more realistic ant behavior
|
||||
- `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
|
||||
|
||||
|
|
|
|||
244
docs/NEST-BUILDING.md
Normal file
244
docs/NEST-BUILDING.md
Normal 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.
|
||||
155
docs/plans/2026-03-11-ant-behavior-overhaul-design.md
Normal file
155
docs/plans/2026-03-11-ant-behavior-overhaul-design.md
Normal 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).
|
||||
1039
docs/plans/2026-03-11-ant-behavior-overhaul.md
Normal file
1039
docs/plans/2026-03-11-ant-behavior-overhaul.md
Normal file
File diff suppressed because it is too large
Load diff
1021
docs/plans/2026-03-11-ant-farm-implementation.md
Normal file
1021
docs/plans/2026-03-11-ant-farm-implementation.md
Normal file
File diff suppressed because it is too large
Load diff
245
docs/plans/2026-03-11-ant-farm-sand-physics-design.md
Normal file
245
docs/plans/2026-03-11-ant-farm-sand-physics-design.md
Normal 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
|
||||
132
index.html
132
index.html
|
|
@ -8,29 +8,135 @@
|
|||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
#sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
height: 100vh;
|
||||
background: #0a0a0a;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#info {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-family: monospace;
|
||||
color: #777;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
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%;
|
||||
text-align: left;
|
||||
font-family: monospace;
|
||||
color: white;
|
||||
padding: 16px;
|
||||
text-shadow: 0 0 3px black;
|
||||
pointer-events: none;
|
||||
min-width: 100%;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
#gui-container .lil-gui .controller .widget {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
canvas {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
<script type="module" src="/src/App.ts"></script>
|
||||
</body>
|
||||
|
|
|
|||
9
justfile
9
justfile
|
|
@ -1,6 +1,9 @@
|
|||
default:
|
||||
@just --list
|
||||
|
||||
dev:
|
||||
bun run dev
|
||||
|
||||
lint:
|
||||
bun run lint
|
||||
|
||||
|
|
@ -11,3 +14,9 @@ test:
|
|||
bun run test
|
||||
|
||||
check: lint typecheck test
|
||||
|
||||
build:
|
||||
bun run build
|
||||
|
||||
neocities: build
|
||||
bashcities -n -p ants push
|
||||
|
|
|
|||
28
src/App.ts
28
src/App.ts
|
|
@ -1,9 +1,11 @@
|
|||
import Config from "./Config";
|
||||
import Config, { saveConfig } from "./Config";
|
||||
import GUI from "./GUI";
|
||||
import Renderer from "./Renderer";
|
||||
import StatsOverlay from "./StatsOverlay";
|
||||
import AntsComputeScene from "./scenes/AntsComputeScene";
|
||||
import AntsDiscretizeScene from "./scenes/AntsDiscretizeScene";
|
||||
import DrawScene from "./scenes/DrawScene";
|
||||
import SandPhysicsScene from "./scenes/SandPhysicsScene";
|
||||
import ScreenScene from "./scenes/ScreenScene";
|
||||
import WorldBlurScene from "./scenes/WorldBlurScene";
|
||||
import WorldComputeScene from "./scenes/WorldComputeScene";
|
||||
|
|
@ -15,6 +17,7 @@ export interface SceneCollection {
|
|||
discretize: AntsDiscretizeScene;
|
||||
screen: ScreenScene;
|
||||
draw: DrawScene;
|
||||
sandPhysics: SandPhysicsScene;
|
||||
}
|
||||
|
||||
export default new (class App {
|
||||
|
|
@ -23,12 +26,14 @@ export default new (class App {
|
|||
);
|
||||
private scenes!: SceneCollection;
|
||||
private gui: GUI = new GUI();
|
||||
private statsOverlay: StatsOverlay = new StatsOverlay();
|
||||
private renderLoop = (time: number): void => this.render(time);
|
||||
private lastTime: number = 0;
|
||||
private queuedSimSteps: number = 0;
|
||||
|
||||
constructor() {
|
||||
this.initScenes();
|
||||
this.resetRenderer();
|
||||
|
||||
window.addEventListener("resize", () => this.resize());
|
||||
|
||||
|
|
@ -39,6 +44,15 @@ export default new (class App {
|
|||
this.gui.on("reset", () => {
|
||||
this.resetRenderer();
|
||||
});
|
||||
|
||||
this.gui.on("zoomChange", () => {
|
||||
this.scenes.screen.applyCameraZoom();
|
||||
});
|
||||
|
||||
window.addEventListener("viewModeToggle", () => {
|
||||
saveConfig();
|
||||
this.resetRenderer();
|
||||
});
|
||||
}
|
||||
|
||||
private resetRenderer() {
|
||||
|
|
@ -53,12 +67,14 @@ export default new (class App {
|
|||
discretize: new AntsDiscretizeScene(this.renderer),
|
||||
screen: new ScreenScene(this.renderer),
|
||||
draw: new DrawScene(this.renderer),
|
||||
sandPhysics: new SandPhysicsScene(this.renderer),
|
||||
};
|
||||
}
|
||||
|
||||
private resize() {
|
||||
const width = window.innerWidth * window.devicePixelRatio;
|
||||
const height = window.innerHeight * window.devicePixelRatio;
|
||||
const canvas = this.renderer.canvas;
|
||||
const width = canvas.clientWidth * window.devicePixelRatio;
|
||||
const height = canvas.clientHeight * window.devicePixelRatio;
|
||||
|
||||
this.renderer.resizeCanvas(width, height);
|
||||
|
||||
|
|
@ -73,6 +89,7 @@ export default new (class App {
|
|||
}
|
||||
|
||||
this.renderer.renderSimulation(this.scenes);
|
||||
this.statsOverlay.recordTick();
|
||||
}
|
||||
|
||||
private render(time: number) {
|
||||
|
|
@ -96,6 +113,11 @@ export default new (class App {
|
|||
|
||||
this.renderer.renderToScreen(this.scenes);
|
||||
|
||||
this.statsOverlay.update(
|
||||
this.scenes.screen.pointerPosition,
|
||||
this.renderer.colonyStatsData,
|
||||
);
|
||||
|
||||
this.lastTime = time;
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export default {
|
||||
const STORAGE_KEY = "ants-simulation-config";
|
||||
|
||||
const defaults = {
|
||||
worldSize: 1024,
|
||||
antsCount: 12,
|
||||
simulationStepsPerSecond: 60,
|
||||
|
|
@ -11,9 +13,51 @@ export default {
|
|||
antSpeed: 1,
|
||||
antRotationAngle: Math.PI / 30,
|
||||
brushRadius: 20,
|
||||
brushMaterial: -1,
|
||||
cameraZoom: 0,
|
||||
gravityDirection: "down" as const,
|
||||
viewMode: "side" as "side" | "top",
|
||||
// per-channel pheromone params
|
||||
repellentFadeOutFactor: 0.0005,
|
||||
repellentBlurRadius: 0.05,
|
||||
repellentMaxPerCell: 10,
|
||||
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;
|
||||
|
|
|
|||
90
src/GUI.ts
90
src/GUI.ts
|
|
@ -1,11 +1,19 @@
|
|||
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;
|
||||
|
||||
class GUIController {
|
||||
private gui: GUI = new GUI({
|
||||
width: 400,
|
||||
container: document.getElementById("gui-container") as HTMLElement,
|
||||
});
|
||||
private listeners: Map<string, EventHandler[]> = new Map();
|
||||
|
||||
|
|
@ -16,33 +24,94 @@ class GUIController {
|
|||
.add(Config, "worldSize", 256, 4096)
|
||||
.name("World size")
|
||||
.step(1)
|
||||
.onChange(() => this.emit("reset"));
|
||||
.onChange(() => this.saveAndEmit("reset"));
|
||||
simFolder
|
||||
.add(Config, "antsCount", 0, 22)
|
||||
.name("Ants count 2^")
|
||||
.step(1)
|
||||
.onChange(() => this.emit("reset"));
|
||||
.onChange(() => this.saveAndEmit("reset"));
|
||||
simFolder
|
||||
.add(Config, "scentFadeOutFactor", 0, 0.01)
|
||||
.name("Pheromone evaporation factor")
|
||||
.step(0.0001)
|
||||
.onChange(() => this.emit("reset"));
|
||||
.onChange(() => this.saveAndEmit("reset"));
|
||||
simFolder
|
||||
.add(Config, "scentBlurRadius", 0, 0.5)
|
||||
.name("Pheromone diffusion factor")
|
||||
.step(0.01)
|
||||
.onChange(() => this.emit("reset"));
|
||||
.onChange(() => this.saveAndEmit("reset"));
|
||||
simFolder
|
||||
.add(Config, "simulationStepsPerSecond", 1, 500)
|
||||
.name("Simulation steps per second")
|
||||
.step(1);
|
||||
.step(1)
|
||||
.onChange(() => saveConfig());
|
||||
|
||||
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();
|
||||
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 {
|
||||
|
|
@ -53,6 +122,11 @@ class GUIController {
|
|||
this.listeners.get(event)!.push(handler);
|
||||
}
|
||||
|
||||
private saveAndEmit(event: string): void {
|
||||
saveConfig();
|
||||
this.emit(event);
|
||||
}
|
||||
|
||||
private emit(event: string): void {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (handlers) {
|
||||
|
|
|
|||
140
src/Renderer.ts
140
src/Renderer.ts
|
|
@ -1,19 +1,35 @@
|
|||
import type { WebGLRenderTarget } from "three";
|
||||
import * as THREE from "three";
|
||||
import type { SceneCollection } from "./App";
|
||||
import ColonyStats from "./ColonyStats";
|
||||
import ColonyStats, { type ColonyStatsData } from "./ColonyStats";
|
||||
import Config from "./Config";
|
||||
import {
|
||||
FOOD_QUALITY_MASK,
|
||||
FOOD_QUALITY_SHIFT,
|
||||
TERRAIN_TYPE_MASK,
|
||||
TERRAIN_TYPE_SHIFT,
|
||||
MAT_AIR,
|
||||
MAT_DIRT,
|
||||
MAT_FOOD,
|
||||
MAT_HOME,
|
||||
MAT_ROCK,
|
||||
MAT_SAND,
|
||||
} 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 {
|
||||
worldRenderTarget: THREE.WebGLRenderTarget;
|
||||
worldRenderTargetCopy: THREE.WebGLRenderTarget;
|
||||
worldBlurredRenderTarget: THREE.WebGLRenderTarget;
|
||||
sandPhysicsRenderTarget: THREE.WebGLRenderTarget;
|
||||
antsComputeTarget0: THREE.WebGLRenderTarget;
|
||||
antsComputeTarget1: THREE.WebGLRenderTarget;
|
||||
antsDiscreteRenderTarget: THREE.WebGLRenderTarget;
|
||||
|
|
@ -23,12 +39,36 @@ interface Resources {
|
|||
export default class Renderer {
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
public resources!: Resources;
|
||||
private frameCounter = 0;
|
||||
private colonyStats = new ColonyStats();
|
||||
public readonly materialRegistry = new MaterialRegistry();
|
||||
public readonly materialPropsTexture!: THREE.DataTexture;
|
||||
public readonly materialColorTexture!: THREE.DataTexture;
|
||||
|
||||
constructor(public canvas: HTMLCanvasElement) {
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas });
|
||||
|
||||
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() {
|
||||
|
|
@ -42,8 +82,8 @@ export default class Renderer {
|
|||
format: THREE.RGBAFormat,
|
||||
type: THREE.FloatType,
|
||||
depthBuffer: false,
|
||||
magFilter: THREE.LinearFilter,
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.NearestFilter,
|
||||
minFilter: THREE.NearestFilter,
|
||||
},
|
||||
),
|
||||
worldRenderTargetCopy: new THREE.WebGLRenderTarget(
|
||||
|
|
@ -68,6 +108,17 @@ export default class Renderer {
|
|||
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),
|
||||
antsComputeTarget1: Renderer.makeAntsMRT(antTextureSize),
|
||||
antsDiscreteRenderTarget: new THREE.WebGLRenderTarget(
|
||||
|
|
@ -116,10 +167,28 @@ export default class Renderer {
|
|||
const [antsComputeSource, antsComputeTarget] =
|
||||
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.renderer.setRenderTarget(this.resources.worldBlurredRenderTarget);
|
||||
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.setRenderTarget(this.resources.antsPresenceRenderTarget);
|
||||
|
|
@ -135,6 +204,8 @@ export default class Renderer {
|
|||
this.resources.worldBlurredRenderTarget.texture;
|
||||
scenes.ants.material.uniforms.tPresence.value =
|
||||
this.resources.antsPresenceRenderTarget.texture;
|
||||
scenes.ants.material.uniforms.uMaterialProps.value =
|
||||
this.materialPropsTexture;
|
||||
this.renderer.render(scenes.ants, scenes.ants.camera);
|
||||
|
||||
this.setViewportFromRT(this.resources.antsDiscreteRenderTarget);
|
||||
|
|
@ -177,7 +248,8 @@ export default class Renderer {
|
|||
this.resources.worldRenderTarget.texture;
|
||||
scenes.draw.material.uniforms.pointerPosition.value =
|
||||
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;
|
||||
this.renderer.render(scenes.draw, scenes.draw.camera);
|
||||
this.renderer.copyFramebufferToTexture(
|
||||
|
|
@ -196,8 +268,7 @@ export default class Renderer {
|
|||
}
|
||||
|
||||
public resizeCanvas(width: number, height: number) {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.renderer.setSize(width, height, false);
|
||||
}
|
||||
|
||||
public getCommonMaterialDefines(): Record<string, string> {
|
||||
|
|
@ -237,10 +308,19 @@ export default class Renderer {
|
|||
REPELLENT_THRESHOLD: Renderer.convertNumberToFloatString(
|
||||
Config.repellentThreshold,
|
||||
),
|
||||
TERRAIN_TYPE_SHIFT: String(TERRAIN_TYPE_SHIFT),
|
||||
TERRAIN_TYPE_MASK: String(TERRAIN_TYPE_MASK),
|
||||
FOOD_QUALITY_SHIFT: String(FOOD_QUALITY_SHIFT),
|
||||
FOOD_QUALITY_MASK: String(FOOD_QUALITY_MASK),
|
||||
MAT_AIR: String(MAT_AIR),
|
||||
MAT_SAND: String(MAT_SAND),
|
||||
MAT_DIRT: String(MAT_DIRT),
|
||||
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.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(
|
||||
Config.worldSize,
|
||||
Config.worldSize,
|
||||
|
|
@ -268,6 +365,15 @@ export default class Renderer {
|
|||
this.renderer.setRenderTarget(this.resources.worldBlurredRenderTarget);
|
||||
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(
|
||||
antTextureSize,
|
||||
antTextureSize,
|
||||
|
|
@ -301,6 +407,10 @@ export default class Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
public get colonyStatsData(): ColonyStatsData {
|
||||
return this.colonyStats.data;
|
||||
}
|
||||
|
||||
static convertNumberToFloatString(n: number): string {
|
||||
return n.toFixed(8);
|
||||
}
|
||||
|
|
|
|||
69
src/StatsOverlay.ts
Normal file
69
src/StatsOverlay.ts
Normal 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
23
src/WorldInit.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -1,66 +1,33 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
CELL_FOOD_BIT,
|
||||
CELL_HOME_BIT,
|
||||
CELL_OBSTACLE_BIT,
|
||||
FOOD_QUALITY_MASK,
|
||||
FOOD_QUALITY_SHIFT,
|
||||
TERRAIN_TYPE_MASK,
|
||||
TERRAIN_TYPE_SHIFT,
|
||||
MAT_AIR,
|
||||
MAT_DIRT,
|
||||
MAT_FOOD,
|
||||
MAT_HOME,
|
||||
MAT_ROCK,
|
||||
MAT_SAND,
|
||||
} from "../constants";
|
||||
|
||||
describe("cell metadata bit layout", () => {
|
||||
test("bit fields do not overlap", () => {
|
||||
// cell flags occupy bits 0-2
|
||||
const cellFlagsBits = (1 << (CELL_OBSTACLE_BIT + 1)) - 1;
|
||||
// terrain occupies bits 3-5
|
||||
const terrainBits = TERRAIN_TYPE_MASK << TERRAIN_TYPE_SHIFT;
|
||||
// food quality occupies bits 6-13
|
||||
const foodQualityBits = FOOD_QUALITY_MASK << FOOD_QUALITY_SHIFT;
|
||||
|
||||
expect(cellFlagsBits & terrainBits).toBe(0);
|
||||
expect(cellFlagsBits & foodQualityBits).toBe(0);
|
||||
expect(terrainBits & foodQualityBits).toBe(0);
|
||||
describe("material ID constants", () => {
|
||||
test("IDs match registry order", () => {
|
||||
expect(MAT_AIR).toBe(0);
|
||||
expect(MAT_SAND).toBe(1);
|
||||
expect(MAT_DIRT).toBe(2);
|
||||
expect(MAT_ROCK).toBe(3);
|
||||
expect(MAT_FOOD).toBe(4);
|
||||
expect(MAT_HOME).toBe(5);
|
||||
});
|
||||
|
||||
test("terrain type can encode 8 values", () => {
|
||||
for (let t = 0; t <= 7; t++) {
|
||||
const packed = t << TERRAIN_TYPE_SHIFT;
|
||||
const unpacked = (packed >> TERRAIN_TYPE_SHIFT) & TERRAIN_TYPE_MASK;
|
||||
expect(unpacked).toBe(t);
|
||||
test("IDs are unique", () => {
|
||||
const ids = [MAT_AIR, MAT_SAND, MAT_DIRT, MAT_ROCK, MAT_FOOD, MAT_HOME];
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
28
src/__tests__/margolus.test.ts
Normal file
28
src/__tests__/margolus.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
121
src/__tests__/materials.test.ts
Normal file
121
src/__tests__/materials.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
87
src/__tests__/worldInit.test.ts
Normal file
87
src/__tests__/worldInit.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 CELL_FOOD_BIT = 0;
|
||||
export const CELL_HOME_BIT = 1;
|
||||
export const CELL_OBSTACLE_BIT = 2;
|
||||
|
||||
// bits 3-5: terrain type (0-7)
|
||||
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
|
||||
export const MAT_AIR = 0;
|
||||
export const MAT_SAND = 1;
|
||||
export const MAT_DIRT = 2;
|
||||
export const MAT_ROCK = 3;
|
||||
export const MAT_FOOD = 4;
|
||||
export const MAT_HOME = 5;
|
||||
|
|
|
|||
11
src/materials/index.ts
Normal file
11
src/materials/index.ts
Normal 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";
|
||||
28
src/materials/lookupTexture.ts
Normal file
28
src/materials/lookupTexture.ts
Normal 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
89
src/materials/registry.ts
Normal 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
23
src/materials/types.ts
Normal 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
4
src/sand/margolus.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function getBlockOffset(frame: number): { x: number; y: number } {
|
||||
const parity = frame & 1;
|
||||
return { x: parity, y: parity };
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export default class AntsComputeScene extends AbstractScene {
|
|||
tLastExtState: { value: null },
|
||||
tWorld: { value: null },
|
||||
tPresence: { value: null },
|
||||
uMaterialProps: { value: null },
|
||||
uForagerRatio: { value: 0 },
|
||||
},
|
||||
vertexShader,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export default class DrawScene extends AbstractScene {
|
|||
uniforms: {
|
||||
tWorld: { value: null },
|
||||
pointerPosition: { value: new THREE.Vector2() },
|
||||
drawMode: { value: 0 },
|
||||
drawMode: { value: -1 },
|
||||
brushRadius: { value: 0 },
|
||||
},
|
||||
vertexShader,
|
||||
|
|
|
|||
43
src/scenes/SandPhysicsScene.ts
Normal file
43
src/scenes/SandPhysicsScene.ts
Normal 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() {}
|
||||
}
|
||||
|
|
@ -1,5 +1,13 @@
|
|||
import * as THREE from "three";
|
||||
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 fragmentShaderAnts from "../shaders/ants.frag";
|
||||
import vertexShaderAnts from "../shaders/ants.vert";
|
||||
|
|
@ -7,23 +15,23 @@ import fragmentShaderGround from "../shaders/screenWorld.frag";
|
|||
import vertexShaderGround from "../shaders/screenWorld.vert";
|
||||
import AbstractScene from "./AbstractScene";
|
||||
|
||||
enum PointerState {
|
||||
None,
|
||||
Food,
|
||||
Home,
|
||||
Obstacle,
|
||||
Erase,
|
||||
}
|
||||
|
||||
export default class ScreenScene extends AbstractScene {
|
||||
public readonly camera: THREE.OrthographicCamera;
|
||||
public readonly material: THREE.ShaderMaterial;
|
||||
public ants!: THREE.InstancedMesh;
|
||||
public readonly groundMaterial: THREE.ShaderMaterial;
|
||||
public readonly pointerPosition: THREE.Vector2 = new THREE.Vector2();
|
||||
public drawMode: PointerState = PointerState.None;
|
||||
private cameraZoomLinear: number = 0;
|
||||
public drawMode: number = -1;
|
||||
// zoom stored in Config.cameraZoom
|
||||
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 renderHeight: number = 1;
|
||||
|
||||
|
|
@ -38,6 +46,9 @@ export default class ScreenScene extends AbstractScene {
|
|||
value: this.renderer.resources.worldRenderTarget
|
||||
.texture,
|
||||
},
|
||||
uMaterialColors: {
|
||||
value: this.renderer.materialColorTexture,
|
||||
},
|
||||
},
|
||||
vertexShader: vertexShaderGround,
|
||||
fragmentShader: fragmentShaderGround,
|
||||
|
|
@ -47,6 +58,7 @@ export default class ScreenScene extends AbstractScene {
|
|||
);
|
||||
|
||||
this.groundMaterial = ground.material;
|
||||
ground.frustumCulled = false;
|
||||
|
||||
this.add(ground);
|
||||
|
||||
|
|
@ -89,8 +101,9 @@ export default class ScreenScene extends AbstractScene {
|
|||
this.renderer.canvas.addEventListener("pointerdown", (e) => {
|
||||
this.isPointerDown = true;
|
||||
|
||||
raycastVector.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
raycastVector.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
const rect = this.renderer.canvas.getBoundingClientRect();
|
||||
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);
|
||||
|
||||
|
|
@ -104,17 +117,18 @@ export default class ScreenScene extends AbstractScene {
|
|||
|
||||
this.renderer.canvas.addEventListener("pointermove", (e) => {
|
||||
if (this.isPointerDown) {
|
||||
const rect = this.renderer.canvas.getBoundingClientRect();
|
||||
const dx = e.movementX;
|
||||
const dy = e.movementY;
|
||||
|
||||
this.camera.position.x -=
|
||||
dx / window.innerHeight / this.camera.zoom;
|
||||
this.camera.position.y +=
|
||||
dy / window.innerHeight / this.camera.zoom;
|
||||
this.camera.position.x -= dx / rect.height / this.camera.zoom;
|
||||
this.camera.position.y += dy / rect.height / this.camera.zoom;
|
||||
this.clampCamera();
|
||||
}
|
||||
|
||||
raycastVector.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
raycastVector.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
const rect = this.renderer.canvas.getBoundingClientRect();
|
||||
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);
|
||||
|
||||
|
|
@ -135,42 +149,84 @@ export default class ScreenScene extends AbstractScene {
|
|||
});
|
||||
|
||||
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.clampCamera();
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
switch (e.code) {
|
||||
case "KeyQ": {
|
||||
this.drawMode = PointerState.Home;
|
||||
this.drawMode = MAT_HOME;
|
||||
break;
|
||||
}
|
||||
case "KeyW": {
|
||||
this.drawMode = PointerState.Food;
|
||||
this.drawMode = MAT_FOOD;
|
||||
break;
|
||||
}
|
||||
case "KeyE": {
|
||||
this.drawMode = PointerState.Obstacle;
|
||||
this.drawMode = MAT_ROCK;
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", () => {
|
||||
this.drawMode = PointerState.None;
|
||||
this.drawMode = -1;
|
||||
});
|
||||
}
|
||||
|
||||
private updateCameraZoom() {
|
||||
this.camera.zoom = 2 ** this.cameraZoomLinear;
|
||||
this.camera.zoom = 2 ** Config.cameraZoom;
|
||||
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() {
|
||||
if (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.frustumCulled = false;
|
||||
|
||||
this.add(ants);
|
||||
|
||||
|
|
@ -214,4 +271,9 @@ export default class ScreenScene extends AbstractScene {
|
|||
}
|
||||
|
||||
public update() {}
|
||||
|
||||
public applyCameraZoom() {
|
||||
this.updateCameraZoom();
|
||||
this.clampCamera();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export default class WorldBlurScene extends AbstractScene {
|
|||
const material = new THREE.RawShaderMaterial({
|
||||
uniforms: {
|
||||
tWorld: { value: null },
|
||||
uMaterialProps: { value: null },
|
||||
},
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ uniform sampler2D tLastExtState;
|
|||
uniform sampler2D tWorld;
|
||||
uniform sampler2D tPresence;
|
||||
uniform float uForagerRatio;
|
||||
uniform sampler2D uMaterialProps;
|
||||
|
||||
const float ANT_CARRY_STRENGTH = 1.0;
|
||||
const float sampleDistance = 20.;
|
||||
const float cellSize = 1. / WORLD_SIZE;
|
||||
|
||||
|
|
@ -32,21 +34,21 @@ vec2 roundUvToCellCenter(vec2 uv) {
|
|||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
|
|
@ -88,19 +90,35 @@ void main() {
|
|||
bool wasObstacle = isObstacle(pos);
|
||||
|
||||
float personality = lastExtState.r;
|
||||
float cargoQuality = lastExtState.g;
|
||||
float cargoMaterialId = lastExtState.g;
|
||||
float pathIntDx = lastExtState.b;
|
||||
float pathIntDy = lastExtState.a;
|
||||
|
||||
bool movementProcessed = false;
|
||||
|
||||
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);
|
||||
angle = rand(vUv * 10000.) * 2. * PI;
|
||||
#endif
|
||||
isCarrying = 0.;
|
||||
storage = 0.;
|
||||
personality = rand(vUv * 42069.); // 0.0 = pure follower, 1.0 = pure explorer
|
||||
cargoQuality = 0.;
|
||||
cargoMaterialId = 0.;
|
||||
pathIntDx = 0.;
|
||||
pathIntDy = 0.;
|
||||
}
|
||||
|
|
@ -162,6 +180,38 @@ void main() {
|
|||
}
|
||||
|
||||
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 sampleAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), isCarrying);
|
||||
|
|
@ -194,10 +244,28 @@ void main() {
|
|||
angle += PI * (noise - 0.5);
|
||||
}
|
||||
|
||||
if (tryGetFood(pos) && isCarrying == 0.) {
|
||||
isCarrying = 1.;
|
||||
angle += PI;
|
||||
storage = getMaxScentStorage(vUv);
|
||||
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.;
|
||||
cargoMaterialId = cellMatId;
|
||||
angle += PI;
|
||||
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)) {
|
||||
|
|
@ -205,15 +273,31 @@ void main() {
|
|||
|
||||
if (isCarrying == 1.) {
|
||||
isCarrying = 0.;
|
||||
cargoMaterialId = 0.;
|
||||
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(
|
||||
pos.x,
|
||||
pos.y,
|
||||
angle,
|
||||
float((uint(max(storage - SCENT_PER_MARKER, 0.)) << 1) + uint(isCarrying))
|
||||
);
|
||||
FragColorExt = vec4(personality, cargoQuality, pathIntDx, pathIntDy);
|
||||
FragColorExt = vec4(personality, cargoMaterialId, pathIntDx, pathIntDy);
|
||||
}
|
||||
|
|
@ -5,9 +5,11 @@ in vec2 vUv;
|
|||
in float vIsCarryingFood;
|
||||
in float vScentFactor;
|
||||
in float vIsCellCleared;
|
||||
in float vDepositMaterialId;
|
||||
|
||||
out vec4 FragColor;
|
||||
|
||||
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.);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ out vec2 vUv;
|
|||
out float vIsCarryingFood;
|
||||
out float vScentFactor;
|
||||
out float vIsCellCleared;
|
||||
out float vDepositMaterialId;
|
||||
|
||||
uniform sampler2D tDataCurrent;
|
||||
uniform sampler2D tDataLast;
|
||||
|
|
@ -38,6 +39,13 @@ void main() {
|
|||
vScentFactor = storage / SCENT_MAX_STORAGE;
|
||||
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(
|
||||
(position.xy * cellSize + floor(offset * WORLD_SIZE) / WORLD_SIZE + cellSize * 0.5) * 2. - 1.,
|
||||
0,
|
||||
|
|
|
|||
|
|
@ -13,24 +13,12 @@ uniform float brushRadius;
|
|||
void main() {
|
||||
vec4 lastState = texture(tWorld, vUv);
|
||||
|
||||
int cellData = int(lastState.x);
|
||||
int isFood = cellData & 1;
|
||||
int isHome = (cellData & 2) >> 1;
|
||||
int isObstacle = (cellData & 4) >> 2;
|
||||
float materialId = lastState.x;
|
||||
|
||||
if (distance(pointerPosition, vUv) < brushRadius / WORLD_SIZE) {
|
||||
if (drawMode == 1.) {
|
||||
isFood = 1;
|
||||
} else if (drawMode == 2.) {
|
||||
isHome = 1;
|
||||
} else if (drawMode == 3.) {
|
||||
isObstacle = 1;
|
||||
} else if (drawMode == 4.) {
|
||||
isFood = 0;
|
||||
isHome = 0;
|
||||
isObstacle = 0;
|
||||
}
|
||||
// drawMode >= 0 means painting that material ID; -1 means not drawing
|
||||
if (drawMode >= 0. && distance(pointerPosition, vUv) < brushRadius / WORLD_SIZE) {
|
||||
materialId = drawMode;
|
||||
}
|
||||
|
||||
FragColor = vec4(float(isFood + (isHome << 1) + (isObstacle << 2)), lastState.yzw);
|
||||
}
|
||||
FragColor = vec4(materialId, lastState.yzw);
|
||||
}
|
||||
|
|
|
|||
112
src/shaders/sandPhysics.frag
Normal file
112
src/shaders/sandPhysics.frag
Normal 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];
|
||||
}
|
||||
12
src/shaders/sandPhysics.vert
Normal file
12
src/shaders/sandPhysics.vert
Normal 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);
|
||||
}
|
||||
|
|
@ -6,20 +6,18 @@ in vec2 vUv;
|
|||
out vec4 FragColor;
|
||||
|
||||
uniform sampler2D map;
|
||||
uniform sampler2D uMaterialColors;
|
||||
|
||||
void main() {
|
||||
vec4 value = texture(map, vUv);
|
||||
|
||||
int cellData = int(value.x);
|
||||
int isFood = cellData & 1;
|
||||
int isHome = (cellData & 2) >> 1;
|
||||
int isObstacle = (cellData & 4) >> 2;
|
||||
int materialId = int(value.x);
|
||||
float toFood = clamp(value.y, 0., 1.);
|
||||
float toHome = clamp(value.z, 0., 1.);
|
||||
|
||||
// pheromone overlay
|
||||
// The part below doen't seem right.
|
||||
// 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;
|
||||
float a = clamp(toHome + toFood, 0., 1.);
|
||||
|
||||
|
|
@ -29,12 +27,10 @@ void main() {
|
|||
|
||||
vec3 color = mix(vec3(1, 1, 1), t, a * 0.7);
|
||||
|
||||
if (isFood == 1) {
|
||||
color = vec3(1, 0.1, 0.1);
|
||||
} else if (isHome == 1) {
|
||||
color = vec3(0.1, 0.1, 1);
|
||||
} else if (isObstacle == 1) {
|
||||
color = vec3(0.6, 0.6, 0.6);
|
||||
// non-air cells use material color as base, with pheromone tint
|
||||
if (materialId != MAT_AIR) {
|
||||
vec4 matColor = texelFetch(uMaterialColors, ivec2(materialId, 0), 0);
|
||||
color = mix(matColor.rgb, t, a * 0.3);
|
||||
}
|
||||
|
||||
FragColor = vec4(color, 1);
|
||||
|
|
|
|||
|
|
@ -12,25 +12,21 @@ void main() {
|
|||
vec4 lastState = texture(tLastState, vUv);
|
||||
vec4 discreteAnts = texture(tDiscreteAnts, vUv);
|
||||
|
||||
int cellData = int(lastState.x);
|
||||
int isFood = cellData & 1;
|
||||
int isHome = (cellData & 2) >> 1;
|
||||
int isObstacle = (cellData & 4) >> 2;
|
||||
float materialId = lastState.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);
|
||||
|
||||
// ant picked up food from this cell
|
||||
if (discreteAnts.z == 1.) {
|
||||
isFood = 0;
|
||||
materialId = float(MAT_AIR);
|
||||
}
|
||||
|
||||
int terrainType = (cellData >> TERRAIN_TYPE_SHIFT) & TERRAIN_TYPE_MASK;
|
||||
int foodQuality = (cellData >> FOOD_QUALITY_SHIFT) & FOOD_QUALITY_MASK;
|
||||
// ant deposited material at this cell (only into air cells)
|
||||
int depositMatId = int(round(discreteAnts.w * 255.));
|
||||
if (depositMatId > 0 && int(materialId) == MAT_AIR) {
|
||||
materialId = float(depositMatId);
|
||||
}
|
||||
|
||||
// reconstruct cell data preserving all bit fields
|
||||
int newCellData = isFood + (isHome << 1) + (isObstacle << 2)
|
||||
+ (terrainType << TERRAIN_TYPE_SHIFT)
|
||||
+ (foodQuality << FOOD_QUALITY_SHIFT);
|
||||
|
||||
FragColor = vec4(float(newCellData), scentToHome, scentToFood, repellent);
|
||||
FragColor = vec4(materialId, scentToHome, scentToFood, repellent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,19 +6,33 @@ in vec2 vUv;
|
|||
out vec4 FragColor;
|
||||
|
||||
uniform sampler2D tWorld;
|
||||
uniform sampler2D uMaterialProps;
|
||||
|
||||
const float offset = 1. / WORLD_SIZE * SCENT_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() {
|
||||
vec4 s0 = texture(tWorld, vUv);
|
||||
float centerAir = isAir(s0.x);
|
||||
vec4 s1 = 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 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 scentToFood = (s0.z + s1.z + s2.z + s3.z + s4.z) / 5. * (1. - SCENT_FADE_OUT_FACTOR);
|
||||
float a1 = isAir(s1.x);
|
||||
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
|
||||
vec4 rr0 = texture(tWorld, vUv);
|
||||
|
|
@ -26,7 +40,14 @@ void main() {
|
|||
vec4 rr2 = 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);
|
||||
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(
|
||||
s0.x,
|
||||
|
|
|
|||
Loading…
Reference in a new issue