Compare commits

..

17 commits

Author SHA1 Message Date
6cef5a35b2
Add debugging notes 2026-03-12 20:14:59 -04:00
81526052f2
Try to fix the ant problem 2026-03-12 20:14:54 -04:00
c8a07d0783
Fix ant rendering GLSL version mismatch causing phantom food carry
The ant rendering ShaderMaterial was missing glslVersion: THREE.GLSL3
while every other material in the project had it. The vert shader used
varying (GLSL1) and the frag used in (GLSL3), causing vIsCarryingFood
to receive garbage values — making all ants appear to carry food from
the first frame regardless of actual state.

Also reverts the debug +20 cell spawn height offset.
2026-03-12 10:25:34 -04:00
5d25218433
Prevent ants from picking up the surface they walk on
Angle noise (±12° from wander) caused the ahead-cell pickup check
to sample into the sand cell directly below the ant. Ants were
grabbing sand on their first frame of existence.

Fix: skip powder pickup when the ahead cell equals the cell directly
below (the walking surface). Food pickup is still allowed from any
adjacent cell. Diagonal dig pickup still works since the DIG priority
angles ants at ~40° which targets a diagonal neighbor, not directly
below.
2026-03-12 10:16:14 -04:00
328f8a76e2
Fix ant physics: pickup, gravity, spawn angle, food drop
- Pickup checks cell ahead instead of current cell (ants walk in
  air and can't enter solid cells, so current cell was always air)
- Starting ants face randomly left/right instead of straight down
  into sand where they'd get stuck bouncing
- Gravity falls up to 4 cells/frame instead of 1 (was imperceptibly
  slow at worldSize=1024)
- tryDropFood checks cell below too so ants walking above home can
  still deposit food
2026-03-12 09:59:04 -04:00
64d7bf6437
Update CLAUDE.md for ant behavior overhaul
Documents the budget pool activation model, priority-stack brain,
ant physics (gravity/collision/surface), unified brush spawning,
new config keys (antsStartCount, antBudget, seedWorld), stats
overlay active/budget format, worldInit test, and screenWorld.frag
color variation.
2026-03-11 22:50:10 -04:00
9f3ecb57e8
Update stats overlay to show active ant count vs budget 2026-03-11 22:50:10 -04:00
4c11924580
Add per-pixel hash noise to material colors 2026-03-11 22:50:10 -04:00
aa3c94091b
Activate dormant ants from spawn buffer in shader 2026-03-11 22:50:10 -04:00
b8f49afcc4
Add ant brush element with keybinding and GUI entry 2026-03-11 22:50:10 -04:00
c81ecaf21e
Add ant spawn uniform to AntsComputeScene 2026-03-11 22:50:10 -04:00
a7aeb064b3
Rewrite ant behavior as priority stack with suppressors 2026-03-11 22:50:10 -04:00
8bf718bbbe
Gate ant initialization by ANTS_START_COUNT budget
Ants whose index exceeds ANTS_START_COUNT output zero state and return
immediately on init, keeping them dormant without consuming GPU work.
This lets the ant texture capacity exceed the active ant count without
spawning unwanted ants.
2026-03-11 22:50:10 -04:00
f41e464b4a
Add ant gravity, surface constraint, and collision displacement
- isObstacle() now blocks all non-air, non-home materials so ants treat sand/dirt/rock/food as walls
- gravity pulls ants down when no solid cell is below them, gating all steering logic while falling
- collision displacement pushes ants to nearest air cell when sand falls on top of them
2026-03-11 22:50:10 -04:00
d4e48af662
Add configurable world seeding with random home and food placement 2026-03-11 20:48:05 -04:00
baadf6cd6d
Add ANTS_START_COUNT and ANT_BUDGET shader defines 2026-03-11 20:46:20 -04:00
f5b9af0ccc
Replace antsCount with antsStartCount and antBudget config 2026-03-11 20:45:31 -04:00
15 changed files with 650 additions and 296 deletions

View file

@ -1,6 +1,6 @@
# ants-simulation # ants-simulation
GPU-accelerated ant colony simulation. ants navigate via pheromone trails, all computed in GLSL fragment shaders rendered to offscreen textures. uses WebGL2 features (MRT, GLSL3). side-view ant farm with sand/powder physics and material-based world. 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. ants use a fixed budget pool with probabilistic activation; behavior is driven by a priority-stack brain (fall → deposit → navigate → forage → dig → wander).
## stack ## stack
@ -27,16 +27,16 @@ all simulation logic runs on the GPU via ping-pong render targets. no JS-side si
1. `SandPhysicsScene` — Margolus block CA for sand/powder physics 1. `SandPhysicsScene` — Margolus block CA for sand/powder physics
2. `WorldBlurScene` — diffuse + decay pheromones (3 channels: toHome, toFood, repellent, blocked by solid cells) 2. `WorldBlurScene` — diffuse + decay pheromones (3 channels: toHome, toFood, repellent, blocked by solid cells)
3. clear `antsPresenceRenderTarget` (ant-ant spatial queries, stub) 3. clear `antsPresenceRenderTarget` (ant-ant spatial queries, stub)
4. `AntsComputeScene` — per-ant state via MRT (writes 2 textures simultaneously), material-aware digging 4. `AntsComputeScene` — per-ant state via MRT (writes 2 textures simultaneously); gravity pass runs first (ants fall through air), then priority-stack brain for active ants; handles collision displacement out of solid cells
5. `AntsDiscretizeScene` — maps continuous ant positions to discrete world grid cells 5. `AntsDiscretizeScene` — maps continuous ant positions to discrete world grid cells
6. `WorldComputeScene` — merges ant deposits into world pheromone grid 6. `WorldComputeScene` — merges ant deposits into world pheromone grid
7. `ColonyStats` — CPU readback of ant texture, computes aggregate stats (foragerRatio), feeds back as uniforms 7. `ColonyStats` — CPU readback of ant texture, computes aggregate stats (foragerRatio, active/budget counts), feeds back as uniforms
8. `DrawScene` — user painting with material palette 8. `DrawScene` — user painting with material palette; brush tool also spawns ants (key: A, `BRUSH_ANTS = 999` sentinel, `uAntSpawn` uniform)
9. `ScreenScene` — final composited output with side/top camera views (V to toggle) 9. `ScreenScene` — final composited output with side/top camera views (V to toggle); passes ant spawn position to AntsComputeScene
### GPU textures ### GPU textures
**ant state** — 2 RGBA Float32 textures per ping-pong target (MRT, `count: 2`): **ant state** — 2 RGBA Float32 textures per ping-pong target (MRT, `count: 2`). texture size is `Math.ceil(Math.sqrt(antBudget))` — a fixed pool. only `antsStartCount` ants activate on init; dormant slots sit at pos=(0,0). new ants can be activated via the brush tool (probabilistic activation per frame when spawn is requested):
- texture 0: `[pos.x, pos.y, angle, packed(storage << 1 | isCarrying)]` - texture 0: `[pos.x, pos.y, angle, packed(storage << 1 | isCarrying)]`
- texture 1: `[personality, cargoMaterialId, pathIntDx, pathIntDy]` - texture 1: `[personality, cargoMaterialId, pathIntDx, pathIntDy]`
@ -72,22 +72,41 @@ Margolus neighborhood cellular automata runs as the first render pass each frame
- physics pass writes back to world ping-pong target before pheromone diffusion runs - 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) - `src/sand/margolus.ts` computes the per-frame block offset (JS side, passed as uniform)
### ant physics and brain
ants run a gravity pass before the priority stack each frame:
- **gravity**: ants in air cells fall downward. steering logic is skipped while falling (`!isFalling` guard).
- **collision displacement**: if an ant ends up inside a solid cell, it is pushed out.
- **surface constraint**: grounded ants follow surface contours.
the priority stack resolves behavior in order, first match wins:
1. **fall** — handled before stack; sets `isFalling`, skips steering
2. **deposit** — drop food at home tile, or deposit carried powder on surface
3. **navigate** — pheromone-following when carrying food; upward bias when carrying powder
4. **forage** — follow food scent when empty
5. **dig** — bias toward diggable powder below surface (side-view only)
6. **wander** — fallback random movement
### key files ### key files
- `src/Renderer.ts` — render target creation, pass orchestration, MRT setup, colony stats readback - `src/Renderer.ts` — render target creation, pass orchestration, MRT setup, colony stats readback
- `src/Config.ts` — simulation parameters (per-channel pheromone configs) - `src/Config.ts` — simulation parameters (per-channel pheromone configs); ant keys: `antsStartCount` (default 4), `antBudget` (pool size, default 512), `seedWorld` (boolean)
- `src/constants.ts` — material IDs (single source of truth for TS + GLSL) - `src/constants.ts` — material IDs (single source of truth for TS + GLSL); `BRUSH_ANTS = 999` sentinel for ant-spawn brush
- `src/ColonyStats.ts` — CPU readback of ant texture for colony-level aggregate stats - `src/ColonyStats.ts` — CPU readback of ant texture for colony-level aggregate stats (reports active/budget)
- `src/StatsOverlay.ts` — on-screen stats display (cursor position, TPS, colony info) - `src/StatsOverlay.ts` — on-screen stats display (cursor position, TPS, colony info); ant count shown as "active / budget"
- `src/__tests__/worldInit.test.ts` — tests for world seeding (home/food placement via mulberry32 PRNG)
- `src/materials/types.ts` — Material interface and BehaviorType constants - `src/materials/types.ts` — Material interface and BehaviorType constants
- `src/materials/registry.ts` — MaterialRegistry with 6 built-in materials - `src/materials/registry.ts` — MaterialRegistry with 6 built-in materials
- `src/materials/lookupTexture.ts` — builds GPU lookup textures from registry - `src/materials/lookupTexture.ts` — builds GPU lookup textures from registry
- `src/sand/margolus.ts` — Margolus block offset calculation - `src/sand/margolus.ts` — Margolus block offset calculation
- `src/scenes/SandPhysicsScene.ts` — sand physics render pass - `src/scenes/SandPhysicsScene.ts` — sand physics render pass
- `src/shaders/antsCompute.frag` — ant behavior + MRT output (2 render targets via layout qualifiers) - `src/shaders/antsCompute.frag` — ant behavior + MRT output (2 render targets via layout qualifiers); gravity, priority stack, spawn activation
- `src/shaders/worldBlur.frag` — per-channel pheromone diffusion/decay (solid cells block diffusion) - `src/shaders/worldBlur.frag` — per-channel pheromone diffusion/decay (solid cells block diffusion)
- `src/shaders/world.frag` — material ID preservation + pheromone merging - `src/shaders/world.frag` — material ID preservation + pheromone merging
- `src/shaders/sandPhysics.frag` — Margolus block CA for powder/sand movement - `src/shaders/sandPhysics.frag` — Margolus block CA for powder/sand movement
- `src/shaders/screenWorld.frag` — final composite; applies per-pixel hash noise (+/-4% brightness) to material colors
## planning docs ## planning docs

View file

@ -0,0 +1,161 @@
air cell corruption debug log
=============================
problem
-------
air cells (materialId=0) in the world render target get corrupted to
materialId=255 after ~10 simulation frames. this causes air to render
as BLACK instead of any intended color, because the material color
lookup texture has no entry at index 255 (returns all zeros).
the original request was to change air from black to light blue.
the shader change is trivial but has no effect because the materialId
is wrong — the shader's `materialId == MAT_AIR` branch never fires.
what we confirmed
-----------------
1. init data is correct: air cells = [0,0,0,0], sand cells = [1,0,0,0]
(sync readback immediately after generateSideViewWorld + copyTextureToTexture)
2. frame 0 is clean: every render pass in the pipeline produces [0,0,0,0]
for an air cell at top-center (px=512, py=1023). checked after each:
- sandPhysicsRT: [0,0,0,0]
- worldBlurredRT: [0,0,0,0]
- antsDiscreteRT: [0,0,0,0]
- worldRT OUTPUT: [0,0,0,0]
3. by frame 10, worldRT INPUT (read before sandPhysics) = [255,0,0,0]
meaning the corruption enters between frame 9's worldCompute output
(which passed the check) and frame 10's sandPhysics input
4. renderToScreen runs between those frames (the sim loop does ~1 step
per rAF, then calls renderToScreen). so the corruption window is
during renderToScreen or between renderToScreen and the next sim step.
5. the screen shader IS being used for the full viewport (confirmed by
outputting solid blue — entire screen turned blue)
6. MAT_AIR define is correctly 0 (logged from ScreenScene constructor)
7. the shader source reaching the material IS the correct file
(first 200 chars logged and verified)
what we ruled out
-----------------
- shader not loading: confirmed via all-blue test and console.debug of source
- material color lookup texture: registry has correct values, texture
generation code (generateColorData) is straightforward
- texture filtering: all render targets use NearestFilter
- sand physics: passes through materialId unchanged for non-swapping cells
- worldBlur: outputs s0.x (material ID) unchanged
- worldCompute (world.frag): preserves materialId from input, only changes
it for deposits (depositMatId > 0) or cell clears (discreteAnts.z == 1)
- draw.frag: preserves materialId unless actively painting (drawMode >= 0)
- vite HMR: shaders load as raw strings at construction, HMR doesn't
reconstruct ShaderMaterial. confirmed shader changes take effect after
full dev server restart
- copyFramebufferToTexture: disabled it, corruption still happened at
frame 10. the copy was suspected because it writes to worldRT.texture
while that texture may still be bound from DrawScene's tWorld uniform.
BUT disabling it did not fix the issue. (this call still needs a proper
fix — either use a render pass copy or unbind the texture first)
what we have NOT tested
-----------------------
- whether the corruption happens without renderToScreen at all (skip it
entirely — won't see anything on screen but could log worldRT state)
- whether ColonyStats.update (readRenderTargetPixels on antTarget) has
side effects that corrupt worldRT. it changes the bound render target.
- whether the draw scene render itself corrupts worldRT via some WebGL
state leak (even though it writes to worldRenderTargetCopy, maybe
binding worldRT.texture as tWorld uniform causes a side effect)
- a minimal repro: disable ALL passes except sandPhysics + worldBlur +
worldCompute and see if corruption still happens (isolate whether
ant passes or screen passes introduce it)
- reading worldRT between renderToScreen and the next renderSimulation
(requires readback in the rAF callback, after renderToScreen returns)
- whether THREE.js 0.173 has known issues with Float32 render targets
and copyFramebufferToTexture / readRenderTargetPixels
render pipeline (per frame)
---------------------------
in renderSimulation():
1. sandPhysics: reads worldRT -> writes sandPhysicsRT
2. worldBlur: reads sandPhysicsRT -> writes worldBlurredRT
3. antsPresence: cleared
4. antsCompute: reads worldBlurredRT + antsPresenceRT -> writes antsComputeTarget (MRT)
5. antsDiscretize: reads antsComputeTarget -> writes antsDiscreteRT (NOT cleared per frame!)
6. worldCompute: reads worldBlurredRT + antsDiscreteRT -> writes worldRT
7. ColonyStats.update: readRenderTargetPixels on antsComputeTarget (CPU readback)
in renderToScreen():
8. drawScene: reads worldRT (tWorld) -> writes worldRenderTargetCopy
9. copyFramebufferToTexture: copies worldRenderTargetCopy framebuffer -> worldRT.texture
(CURRENTLY DISABLED in local changes)
10. screenScene: reads worldRenderTargetCopy (map) -> writes to canvas (null target)
worldRT is the persistent world state. it feeds back into itself:
worldRT -> sandPhysics -> worldBlur -> worldCompute -> worldRT
current local changes (uncommitted)
------------------------------------
1. src/shaders/screenWorld.frag:
- air background color changed from white vec3(1,1,1) to light blue vec3(0.53, 0.81, 0.92)
- this change works correctly IF materialId is 0 for air cells
- also: the committed version has stale debug grayscale code from a previous session
that needs to be cleaned up
2. src/Renderer.ts:
- copyFramebufferToTexture disabled (replaced with comment)
- minor whitespace change
- both should be reverted when the real fix is found
3. src/scenes/ScreenScene.ts:
- groundDefines extracted to variable (cosmetic, from debug session)
4. src/materials/registry.ts:
- air color changed from [0,0,0,0] to [0.53,0.81,0.92,1.0]
- this was an early attempt that doesn't matter since the shader
hardcodes the air branch (doesn't use the lookup for air)
key file locations
------------------
- world init: src/WorldInit.ts (generateSideViewWorld)
- render pipeline: src/Renderer.ts (renderSimulation + renderToScreen)
- world texture format: RGBA Float32, R=materialId, G=scentToHome, B=scentToFood, A=repellent
- screen shader: src/shaders/screenWorld.frag
- world compute: src/shaders/world.frag
- sand physics: src/shaders/sandPhysics.frag
- pheromone blur: src/shaders/worldBlur.frag
- draw shader: src/shaders/draw.frag
- material constants: src/constants.ts (MAT_AIR=0 through MAT_HOME=5)
- material registry: src/materials/registry.ts
- color lookup texture gen: src/materials/lookupTexture.ts
suggested next steps
--------------------
1. try disabling renderToScreen entirely and adding a single
console.debug readback of worldRT after 20 frames. if worldRT stays
[0,0,0,0] for air, the corruption is caused by renderToScreen or
something it triggers.
2. if step 1 still shows corruption, try disabling the ant passes
(antsCompute, antsDiscretize, antsPresence clear) and see if the
corruption disappears. the world pipeline without ants is just
sandPhysics -> worldBlur -> worldCompute, which should be a no-op
for air cells.
3. if step 2 still shows corruption, the issue might be in sandPhysics
(Margolus block CA) or worldCompute. try disabling each individually.
4. check THREE.js 0.173 changelog/issues for Float32 render target bugs.
5. consider adding a "sanitizer" pass that clamps materialId to [0,5]
at the start of each frame as a workaround while debugging.

View file

@ -2,7 +2,9 @@ const STORAGE_KEY = "ants-simulation-config";
const defaults = { const defaults = {
worldSize: 1024, worldSize: 1024,
antsCount: 12, antsStartCount: 4,
antBudget: 512,
seedWorld: true,
simulationStepsPerSecond: 60, simulationStepsPerSecond: 60,
scentThreshold: 0.01, scentThreshold: 0.01,
scentFadeOutFactor: 0.001, scentFadeOutFactor: 0.001,

View file

@ -26,10 +26,14 @@ class GUIController {
.step(1) .step(1)
.onChange(() => this.saveAndEmit("reset")); .onChange(() => this.saveAndEmit("reset"));
simFolder simFolder
.add(Config, "antsCount", 0, 22) .add(Config, "antsStartCount", 0, 64)
.name("Ants count 2^") .name("Starting ants")
.step(1) .step(1)
.onChange(() => this.saveAndEmit("reset")); .onChange(() => this.saveAndEmit("reset"));
simFolder
.add(Config, "seedWorld")
.name("Seed home + food")
.onChange(() => this.saveAndEmit("reset"));
simFolder simFolder
.add(Config, "scentFadeOutFactor", 0, 0.01) .add(Config, "scentFadeOutFactor", 0, 0.01)
.name("Pheromone evaporation factor") .name("Pheromone evaporation factor")
@ -69,6 +73,7 @@ class GUIController {
"rock (E)": MAT_ROCK, "rock (E)": MAT_ROCK,
"food (W)": MAT_FOOD, "food (W)": MAT_FOOD,
"home (Q)": MAT_HOME, "home (Q)": MAT_HOME,
"ants (A)": 999,
}; };
// proxy object for lil-gui string dropdown — initialize from saved config // proxy object for lil-gui string dropdown — initialize from saved config

View file

@ -47,6 +47,10 @@ export default class Renderer {
constructor(public canvas: HTMLCanvasElement) { constructor(public canvas: HTMLCanvasElement) {
this.renderer = new THREE.WebGLRenderer({ canvas }); this.renderer = new THREE.WebGLRenderer({ canvas });
// clear alpha must be 0: UnsignedByte render targets (antsDiscreteRT) retain
// the clear value in pixels not covered by instanced draws. alpha=1 produces
// byte 255, which world.frag misinterprets as depositMatId=255.
this.renderer.setClearColor(0x000000, 0);
this.initResources(); this.initResources();
@ -72,7 +76,7 @@ export default class Renderer {
} }
private initResources() { private initResources() {
const antTextureSize = Math.round(Math.sqrt(2 ** Config.antsCount)); const antTextureSize = Math.ceil(Math.sqrt(Config.antBudget));
this.resources = { this.resources = {
worldRenderTarget: new THREE.WebGLRenderTarget( worldRenderTarget: new THREE.WebGLRenderTarget(
@ -181,6 +185,7 @@ export default class Renderer {
); );
scenes.sandPhysics.material.uniforms.uFrame.value = this.frameCounter; scenes.sandPhysics.material.uniforms.uFrame.value = this.frameCounter;
this.renderer.render(scenes.sandPhysics, scenes.sandPhysics.camera); this.renderer.render(scenes.sandPhysics, scenes.sandPhysics.camera);
this.frameCounter++; this.frameCounter++;
this.setViewportFromRT(this.resources.worldBlurredRenderTarget); this.setViewportFromRT(this.resources.worldBlurredRenderTarget);
@ -206,6 +211,11 @@ export default class Renderer {
this.resources.antsPresenceRenderTarget.texture; this.resources.antsPresenceRenderTarget.texture;
scenes.ants.material.uniforms.uMaterialProps.value = scenes.ants.material.uniforms.uMaterialProps.value =
this.materialPropsTexture; this.materialPropsTexture;
scenes.ants.material.uniforms.uAntSpawn.value.copy(
scenes.screen.antSpawnRequest,
);
// clear after reading
scenes.screen.antSpawnRequest.set(0, 0, 0, 0);
this.renderer.render(scenes.ants, scenes.ants.camera); this.renderer.render(scenes.ants, scenes.ants.camera);
this.setViewportFromRT(this.resources.antsDiscreteRenderTarget); this.setViewportFromRT(this.resources.antsDiscreteRenderTarget);
@ -321,11 +331,13 @@ export default class Renderer {
BEHAVIOR_GAS: Renderer.convertNumberToFloatString(BEHAVIOR_GAS), BEHAVIOR_GAS: Renderer.convertNumberToFloatString(BEHAVIOR_GAS),
BEHAVIOR_SOLID: Renderer.convertNumberToFloatString(BEHAVIOR_SOLID), BEHAVIOR_SOLID: Renderer.convertNumberToFloatString(BEHAVIOR_SOLID),
VIEW_MODE_SIDE: Config.viewMode === "side" ? "1" : "0", VIEW_MODE_SIDE: Config.viewMode === "side" ? "1" : "0",
ANTS_START_COUNT: String(Config.antsStartCount),
ANT_BUDGET: String(Config.antBudget),
}; };
} }
public reset(scenes: SceneCollection) { public reset(scenes: SceneCollection) {
const antTextureSize = Math.round(Math.sqrt(2 ** Config.antsCount)); const antTextureSize = Math.ceil(Math.sqrt(Config.antBudget));
this.resources.worldRenderTarget.setSize( this.resources.worldRenderTarget.setSize(
Config.worldSize, Config.worldSize,
@ -335,7 +347,10 @@ export default class Renderer {
this.renderer.clear(); this.renderer.clear();
if (Config.viewMode === "side") { if (Config.viewMode === "side") {
const data = generateSideViewWorld(Config.worldSize); const data = generateSideViewWorld(
Config.worldSize,
Config.seedWorld,
);
const initTexture = new THREE.DataTexture( const initTexture = new THREE.DataTexture(
data, data,
Config.worldSize, Config.worldSize,

View file

@ -60,7 +60,7 @@ export default class StatsOverlay {
this.tpsEl.textContent = `${Math.round(tps)}`; this.tpsEl.textContent = `${Math.round(tps)}`;
} }
this.antsEl.textContent = `${colonyStats.totalAnts}`; this.antsEl.textContent = `${colonyStats.totalAnts} / ${Config.antBudget}`;
const carrying = Math.round( const carrying = Math.round(
colonyStats.foragerRatio * colonyStats.totalAnts, colonyStats.foragerRatio * colonyStats.totalAnts,
); );

View file

@ -1,6 +1,20 @@
import { MAT_HOME, MAT_SAND } from "./constants"; import { MAT_FOOD, MAT_HOME, MAT_SAND } from "./constants";
export function generateSideViewWorld(worldSize: number): Float32Array { // simple seeded PRNG for deterministic placement
function mulberry32(seed: number) {
return () => {
seed += 0x6d2b79f5;
let t = seed;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function generateSideViewWorld(
worldSize: number,
seed: boolean,
): Float32Array {
const data = new Float32Array(worldSize * worldSize * 4); const data = new Float32Array(worldSize * worldSize * 4);
const sandHeight = Math.floor(worldSize * 0.6); const sandHeight = Math.floor(worldSize * 0.6);
const surfaceRow = sandHeight - 1; const surfaceRow = sandHeight - 1;
@ -14,10 +28,23 @@ export function generateSideViewWorld(worldSize: number): Float32Array {
} }
// top 40% stays MAT_AIR (Float32Array is zero-initialized) // top 40% stays MAT_AIR (Float32Array is zero-initialized)
// place home on surface near center if (seed) {
const centerX = Math.floor(worldSize / 2); const rng = mulberry32(Date.now());
const homeIdx = (surfaceRow * worldSize + centerX) * 4; // place home at random surface position (middle 60% of world width)
const margin = Math.floor(worldSize * 0.2);
const homeX = margin + Math.floor(rng() * (worldSize - 2 * margin));
const homeIdx = (surfaceRow * worldSize + homeX) * 4;
data[homeIdx] = MAT_HOME; data[homeIdx] = MAT_HOME;
// place food at a different random surface position
let foodX = homeX;
while (foodX === homeX) {
foodX = margin + Math.floor(rng() * (worldSize - 2 * margin));
}
const foodIdx = (surfaceRow * worldSize + foodX) * 4;
data[foodIdx] = MAT_FOOD;
}
return data; return data;
} }

View file

@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { defaults } from "../Config";
import { import {
MAT_AIR, MAT_AIR,
MAT_DIRT, MAT_DIRT,
@ -31,3 +32,22 @@ describe("material ID constants", () => {
} }
}); });
}); });
describe("config defaults", () => {
test("antsStartCount is a direct count, not an exponent", () => {
expect(defaults.antsStartCount).toBe(4);
expect(defaults.antsStartCount).toBeLessThanOrEqual(64);
});
test("antBudget determines texture pool size", () => {
expect(defaults.antBudget).toBe(512);
});
test("seedWorld defaults to true", () => {
expect(defaults.seedWorld).toBe(true);
});
test("old antsCount key does not exist", () => {
expect("antsCount" in defaults).toBe(false);
});
});

View file

@ -1,87 +1,78 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { MAT_AIR, MAT_HOME, MAT_SAND } from "../constants"; import { MAT_AIR, MAT_FOOD, MAT_HOME, MAT_SAND } from "../constants";
import { generateSideViewWorld } from "../WorldInit"; import { generateSideViewWorld } from "../WorldInit";
const SIZE = 64;
describe("generateSideViewWorld", () => { describe("generateSideViewWorld", () => {
test("output length is worldSize * worldSize * 4", () => { const worldSize = 64;
const data = generateSideViewWorld(SIZE);
expect(data.length).toBe(SIZE * SIZE * 4); test("bottom 60% is sand", () => {
const data = generateSideViewWorld(worldSize, true);
const sandHeight = Math.floor(worldSize * 0.6);
const midY = Math.floor(sandHeight / 2);
const idx = (midY * worldSize + 10) * 4;
expect(data[idx]).toBe(MAT_SAND);
}); });
test("bottom 60% of rows have R = MAT_SAND", () => { test("top 40% is air", () => {
const data = generateSideViewWorld(SIZE); const data = generateSideViewWorld(worldSize, true);
const sandHeight = Math.floor(SIZE * 0.6); const sandHeight = Math.floor(worldSize * 0.6);
for (let y = 0; y < sandHeight; y++) { const airY = sandHeight + 5;
for (let x = 0; x < SIZE; x++) { const idx = (airY * worldSize + 10) * 4;
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); expect(data[idx]).toBe(MAT_AIR);
}
}
}); });
test("G, B, A channels are 0 everywhere", () => { test("seed=true places exactly one home and one food on surface", () => {
const data = generateSideViewWorld(SIZE); const data = generateSideViewWorld(worldSize, true);
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; let homeCount = 0;
for (let i = 0; i < SIZE * SIZE; i++) { let foodCount = 0;
if (data[i * 4] === MAT_HOME) { for (let i = 0; i < data.length; i += 4) {
homeCount++; if (data[i] === MAT_HOME) homeCount++;
if (data[i] === MAT_FOOD) foodCount++;
} }
}
expect(homeCount).toBe(1); expect(homeCount).toBe(1);
expect(foodCount).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", () => { test("seed=true places home and food on the surface row", () => {
const data = generateSideViewWorld(SIZE); const data = generateSideViewWorld(worldSize, true);
const surfaceRow = Math.floor(SIZE * 0.6) - 1; const sandHeight = Math.floor(worldSize * 0.6);
const surfaceRow = sandHeight - 1;
for (let x = 0; x < SIZE; x++) { let homeY = -1;
const idx = (surfaceRow * SIZE + x) * 4; let foodY = -1;
const mat = data[idx]; for (let y = 0; y < worldSize; y++) {
expect(mat === MAT_SAND || mat === MAT_HOME).toBe(true); for (let x = 0; x < worldSize; x++) {
const idx = (y * worldSize + x) * 4;
if (data[idx] === MAT_HOME) homeY = y;
if (data[idx] === MAT_FOOD) foodY = y;
} }
}
expect(homeY).toBe(surfaceRow);
expect(foodY).toBe(surfaceRow);
});
test("seed=false places no home or food", () => {
const data = generateSideViewWorld(worldSize, false);
let homeCount = 0;
let foodCount = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i] === MAT_HOME) homeCount++;
if (data[i] === MAT_FOOD) foodCount++;
}
expect(homeCount).toBe(0);
expect(foodCount).toBe(0);
});
test("seed=true places home and food at different x positions", () => {
const data = generateSideViewWorld(worldSize, true);
let homeX = -1;
let foodX = -1;
for (let y = 0; y < worldSize; y++) {
for (let x = 0; x < worldSize; x++) {
const idx = (y * worldSize + x) * 4;
if (data[idx] === MAT_HOME) homeX = x;
if (data[idx] === MAT_FOOD) foodX = x;
}
}
expect(homeX).not.toBe(foodX);
}); });
}); });

View file

@ -11,7 +11,7 @@ const BUILTIN_MATERIALS: Material[] = [
name: "air", name: "air",
behavior: BEHAVIOR_GAS, behavior: BEHAVIOR_GAS,
density: 0, density: 0,
color: [0, 0, 0, 0], color: [0.53, 0.81, 0.92, 1.0],
hardness: 0, hardness: 0,
angleOfRepose: 0, angleOfRepose: 0,
}, },

View file

@ -24,6 +24,7 @@ export default class AntsComputeScene extends AbstractScene {
tPresence: { value: null }, tPresence: { value: null },
uMaterialProps: { value: null }, uMaterialProps: { value: null },
uForagerRatio: { value: 0 }, uForagerRatio: { value: 0 },
uAntSpawn: { value: new THREE.Vector4(0, 0, 0, 0) },
}, },
vertexShader, vertexShader,
fragmentShader, fragmentShader,

View file

@ -15,29 +15,49 @@ import fragmentShaderGround from "../shaders/screenWorld.frag";
import vertexShaderGround from "../shaders/screenWorld.vert"; import vertexShaderGround from "../shaders/screenWorld.vert";
import AbstractScene from "./AbstractScene"; import AbstractScene from "./AbstractScene";
export const BRUSH_ANTS = 999;
export default class ScreenScene extends AbstractScene { export default class ScreenScene extends AbstractScene {
public readonly camera: THREE.OrthographicCamera; public readonly camera: THREE.OrthographicCamera;
public readonly material: THREE.ShaderMaterial; public readonly material: THREE.ShaderMaterial;
public ants!: THREE.InstancedMesh; public ants!: THREE.InstancedMesh;
public readonly groundMaterial: THREE.ShaderMaterial; public readonly groundMaterial: THREE.ShaderMaterial;
public readonly pointerPosition: THREE.Vector2 = new THREE.Vector2(); public readonly pointerPosition: THREE.Vector2 = new THREE.Vector2();
public readonly antSpawnRequest: THREE.Vector4 = new THREE.Vector4(
0,
0,
0,
0,
);
public drawMode: number = -1; public drawMode: number = -1;
// zoom stored in Config.cameraZoom // zoom stored in Config.cameraZoom
private isPointerDown: boolean = false; private isPointerDown: boolean = false;
// resolves active draw mode: key-held takes priority, then GUI brush selection // resolves active draw mode: key-held takes priority, then GUI brush selection
public get effectiveDrawMode(): number { public get effectiveDrawMode(): number {
if (this.drawMode === BRUSH_ANTS) return -1;
if (this.drawMode >= 0) return this.drawMode; if (this.drawMode >= 0) return this.drawMode;
if (this.isPointerDown && Config.brushMaterial >= 0) if (this.isPointerDown && Config.brushMaterial >= 0) {
if (Config.brushMaterial === BRUSH_ANTS) return -1;
return Config.brushMaterial; return Config.brushMaterial;
}
return -1; return -1;
} }
public get isAntBrushActive(): boolean {
if (this.drawMode === BRUSH_ANTS) return true;
if (this.isPointerDown && Config.brushMaterial === BRUSH_ANTS)
return true;
return false;
}
public renderWidth: number = 1; public renderWidth: number = 1;
public renderHeight: number = 1; public renderHeight: number = 1;
constructor(renderer: Renderer) { constructor(renderer: Renderer) {
super(renderer); super(renderer);
const groundDefines = this.renderer.getCommonMaterialDefines();
const ground = new THREE.Mesh( const ground = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1), new THREE.PlaneGeometry(1, 1),
new THREE.ShaderMaterial({ new THREE.ShaderMaterial({
@ -52,7 +72,7 @@ export default class ScreenScene extends AbstractScene {
}, },
vertexShader: vertexShaderGround, vertexShader: vertexShaderGround,
fragmentShader: fragmentShaderGround, fragmentShader: fragmentShaderGround,
defines: this.renderer.getCommonMaterialDefines(), defines: groundDefines,
glslVersion: THREE.GLSL3, glslVersion: THREE.GLSL3,
}), }),
); );
@ -81,6 +101,7 @@ export default class ScreenScene extends AbstractScene {
vertexShader: vertexShaderAnts, vertexShader: vertexShaderAnts,
fragmentShader: fragmentShaderAnts, fragmentShader: fragmentShaderAnts,
transparent: true, transparent: true,
glslVersion: THREE.GLSL3,
}); });
this.createInstancedAntsMesh(); this.createInstancedAntsMesh();
@ -182,6 +203,10 @@ export default class ScreenScene extends AbstractScene {
this.drawMode = MAT_DIRT; this.drawMode = MAT_DIRT;
break; break;
} }
case "KeyA": {
this.drawMode = BRUSH_ANTS;
break;
}
case "KeyV": { case "KeyV": {
Config.viewMode = Config.viewMode =
Config.viewMode === "side" ? "top" : "side"; Config.viewMode === "side" ? "top" : "side";
@ -270,7 +295,16 @@ export default class ScreenScene extends AbstractScene {
this.renderHeight = height; this.renderHeight = height;
} }
public update() {} public update() {
if (this.isAntBrushActive && this.isPointerDown) {
this.antSpawnRequest.set(
this.pointerPosition.x,
this.pointerPosition.y,
Config.brushRadius,
1, // flag: spawn requested
);
}
}
public applyCameraZoom() { public applyCameraZoom() {
this.updateCameraZoom(); this.updateCameraZoom();

View file

@ -3,8 +3,8 @@ precision highp int;
#define PI 3.1415926535897932384626433832795 #define PI 3.1415926535897932384626433832795
varying vec2 vUv; out vec2 vUv;
varying float vIsCarryingFood; out float vIsCarryingFood;
uniform sampler2D tData; uniform sampler2D tData;

View file

@ -15,6 +15,7 @@ uniform sampler2D tWorld;
uniform sampler2D tPresence; uniform sampler2D tPresence;
uniform float uForagerRatio; uniform float uForagerRatio;
uniform sampler2D uMaterialProps; uniform sampler2D uMaterialProps;
uniform vec4 uAntSpawn;
const float ANT_CARRY_STRENGTH = 1.0; const float ANT_CARRY_STRENGTH = 1.0;
const float sampleDistance = 20.; const float sampleDistance = 20.;
@ -33,22 +34,17 @@ vec2 roundUvToCellCenter(vec2 uv) {
return floor(uv * WORLD_SIZE) / WORLD_SIZE + cellSize * 0.5; return floor(uv * WORLD_SIZE) / WORLD_SIZE + cellSize * 0.5;
} }
bool tryGetFood(vec2 pos) {
float materialId = texture(tWorld, roundUvToCellCenter(pos)).x;
return int(materialId) == MAT_FOOD;
}
bool tryDropFood(vec2 pos) { bool tryDropFood(vec2 pos) {
float materialId = texture(tWorld, roundUvToCellCenter(pos)).x; float currentMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
float belowMatId = texture(tWorld, roundUvToCellCenter(pos - vec2(0., cellSize))).x;
return int(materialId) == MAT_HOME; return int(currentMatId) == MAT_HOME || int(belowMatId) == MAT_HOME;
} }
bool isObstacle(vec2 pos) { bool isObstacle(vec2 pos) {
float materialId = texture(tWorld, roundUvToCellCenter(pos)).x; float materialId = texture(tWorld, roundUvToCellCenter(pos)).x;
int matInt = int(materialId);
return int(materialId) == MAT_ROCK; // ants can only move through air and home cells
return matInt != MAT_AIR && matInt != MAT_HOME;
} }
float smell(vec2 pos, float isCarrying) { float smell(vec2 pos, float isCarrying) {
@ -94,9 +90,48 @@ void main() {
float pathIntDx = lastExtState.b; float pathIntDx = lastExtState.b;
float pathIntDy = lastExtState.a; float pathIntDy = lastExtState.a;
bool movementProcessed = false; // calculate this ant's index from its UV position in the texture
ivec2 texSize = textureSize(tLastState, 0);
int antIndex = int(vUv.y * float(texSize.y)) * texSize.x + int(vUv.x * float(texSize.x));
if (pos == vec2(0)) { // init new ant if (pos == vec2(0)) { // init new ant
bool spawnRequested = (uAntSpawn.w > 0.5);
if (antIndex >= ANTS_START_COUNT && !spawnRequested) {
// dormant ant, no spawn request — skip
FragColor = vec4(0);
FragColorExt = vec4(0);
return;
}
if (antIndex >= ANTS_START_COUNT && spawnRequested) {
// probabilistic activation: ~brushRadius ants per frame
float activationChance = uAntSpawn.z / float(ANT_BUDGET);
float roll = rand(vUv * 50000. + fract(uTime / 1000.));
if (roll > activationChance) {
// not selected this frame — stay dormant
FragColor = vec4(0);
FragColorExt = vec4(0);
return;
}
// activate at spawn position with scatter
float scatter = uAntSpawn.z / WORLD_SIZE;
float rngX = rand(vUv * 10000. + fract(uTime / 1000.));
float rngY = rand(vUv * 20000. + fract(uTime / 1000.) + 0.5);
pos = vec2(
uAntSpawn.x + (rngX - 0.5) * scatter,
uAntSpawn.y + (rngY - 0.5) * scatter
);
pos = clamp(pos, 0., 1.);
angle = rand(vUv * 42069.) * 2.0 * PI;
isCarrying = 0.;
storage = 0.;
personality = rand(vUv * 42069.);
cargoMaterialId = 0.;
pathIntDx = 0.;
pathIntDy = 0.;
} else {
// normal init for starting ants
#if VIEW_MODE_SIDE #if VIEW_MODE_SIDE
// spawn on sand surface: random X, scan down from top to find first non-air cell // spawn on sand surface: random X, scan down from top to find first non-air cell
float spawnX = rand(vUv * 10000.); float spawnX = rand(vUv * 10000.);
@ -110,7 +145,7 @@ void main() {
} }
} }
pos = vec2(spawnX, surfaceY); pos = vec2(spawnX, surfaceY);
angle = -PI * 0.5; // face downward initially angle = (rand(vUv * 31337.) > 0.5) ? 0.0 : PI; // face randomly left or right
#else #else
pos = vec2(0.5); pos = vec2(0.5);
angle = rand(vUv * 10000.) * 2. * PI; angle = rand(vUv * 10000.) * 2. * PI;
@ -122,114 +157,165 @@ void main() {
pathIntDx = 0.; pathIntDx = 0.;
pathIntDy = 0.; pathIntDy = 0.;
} }
if (isCarrying == 0.) {
if (noise < 0.33) {
vec2 offset = vec2(cos(angle), sin(angle)) * sampleDistance;
vec2 point = applyOffsetToPos(pos, offset);
if (tryGetFood(point)) {
movementProcessed = true;
}
} else if (noise < 0.66) {
float newAngle = angle - ANT_ROTATION_ANGLE;
vec2 offset = vec2(cos(newAngle), sin(newAngle)) * sampleDistance;
vec2 point = applyOffsetToPos(pos, offset);
if (tryGetFood(point)) {
movementProcessed = true;
angle = newAngle;
}
} else {
float newAngle = angle + ANT_ROTATION_ANGLE;
vec2 offset = vec2(cos(newAngle), sin(newAngle)) * sampleDistance;
vec2 point = applyOffsetToPos(pos, offset);
if (tryGetFood(point)) {
movementProcessed = true;
angle = newAngle;
}
}
} else if (isCarrying == 1.) {
if (noise < 0.33) {
vec2 offset = vec2(cos(angle), sin(angle)) * sampleDistance;
vec2 point = applyOffsetToPos(pos, offset);
if (tryDropFood(point)) {
movementProcessed = true;
}
} else if (noise < 0.66) {
float newAngle = angle - ANT_ROTATION_ANGLE;
vec2 offset = vec2(cos(newAngle), sin(newAngle)) * sampleDistance;
vec2 point = applyOffsetToPos(pos, offset);
if (tryDropFood(point)) {
movementProcessed = true;
angle = newAngle;
}
} else {
float newAngle = angle + ANT_ROTATION_ANGLE;
vec2 offset = vec2(cos(newAngle), sin(newAngle)) * sampleDistance;
vec2 point = applyOffsetToPos(pos, offset);
if (tryDropFood(point)) {
movementProcessed = true;
angle = newAngle;
}
}
} }
if (!movementProcessed) { // --- GRAVITY AND SURFACE CHECK ---
#if VIEW_MODE_SIDE vec2 cellCenter = roundUvToCellCenter(pos);
// vertical bias for digging behavior float belowMat = texture(tWorld, cellCenter - vec2(0., cellSize)).x;
if (isCarrying == 1. && int(cargoMaterialId) != MAT_FOOD) { float currentMat = texture(tWorld, cellCenter).x;
// carrying powder: bias upward toward surface
float upwardBias = PI * 0.5; // straight up bool belowSolid = (int(belowMat) != MAT_AIR);
float angleDiff = upwardBias - angle;
// normalize to [-PI, PI] // collision displacement: if current cell is now solid (sand fell on us), push to nearest air
angleDiff = mod(angleDiff + PI, 2.0 * PI) - PI; if (int(currentMat) != MAT_AIR && int(currentMat) != MAT_HOME) {
// gentle steering toward up (blend 30% toward upward) vec2 upPos = cellCenter + vec2(0., cellSize);
angle += angleDiff * 0.3; vec2 leftPos = cellCenter - vec2(cellSize, 0.);
} else if (isCarrying == 0.) { vec2 rightPos = cellCenter + vec2(cellSize, 0.);
// not carrying: check if surrounded by diggable material if (int(texture(tWorld, upPos).x) == MAT_AIR) {
// if so, prefer downward movement pos = upPos;
} else if (int(texture(tWorld, leftPos).x) == MAT_AIR) {
pos = leftPos;
} else if (int(texture(tWorld, rightPos).x) == MAT_AIR) {
pos = rightPos;
}
cellCenter = roundUvToCellCenter(pos);
belowMat = texture(tWorld, cellCenter - vec2(0., cellSize)).x;
belowSolid = (int(belowMat) != MAT_AIR);
}
bool isFalling = false;
// GRAVITY: if nothing solid below and not at world bottom, fall multiple cells
if (!belowSolid && pos.y > cellSize) {
for (int g = 0; g < 4; g++) {
if (pos.y <= cellSize) break;
float matBelow = texture(tWorld, roundUvToCellCenter(pos - vec2(0., cellSize))).x;
if (int(matBelow) != MAT_AIR) break;
pos.y -= cellSize;
}
isFalling = true;
}
if (!isFalling) {
bool acted = false;
// ---- PRIORITY 1: DEPOSIT ----
if (!acted && isCarrying == 1.) {
float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x; float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
vec4 props = texelFetch(uMaterialProps, ivec2(int(cellMatId), 0), 0);
// check cell below for diggable material // carrying food and at home -> drop food
vec2 belowUv = roundUvToCellCenter(pos - vec2(0., cellSize)); if (int(cargoMaterialId) == MAT_FOOD && tryDropFood(pos)) {
float belowMat = texture(tWorld, belowUv).x; isCarrying = 0.;
vec4 belowProps = texelFetch(uMaterialProps, ivec2(int(belowMat), 0), 0); cargoMaterialId = 0.;
angle += PI;
storage = getMaxScentStorage(vUv);
acted = true;
}
if (belowProps.r == BEHAVIOR_POWDER && belowProps.b <= ANT_CARRY_STRENGTH) { // carrying powder and on surface (air cell, solid below) -> deposit
// diggable material below — bias downward if (!acted && int(cargoMaterialId) != MAT_FOOD) {
float downwardBias = -PI * 0.5; // straight down if (int(cellMatId) == MAT_AIR) {
float angleDiff = downwardBias - angle; vec2 belowPos = pos - vec2(0., cellSize);
float belowMatId = texture(tWorld, roundUvToCellCenter(belowPos)).x;
vec2 abovePos = pos + vec2(0., cellSize);
float aboveMatId = texture(tWorld, roundUvToCellCenter(abovePos)).x;
if ((int(belowMatId) != MAT_AIR || belowPos.y <= 0.)
&& int(aboveMatId) == MAT_AIR) {
isCarrying = 0.;
// keep cargoMaterialId set so discretize reads it this frame
angle += PI;
storage = getMaxScentStorage(vUv);
acted = true;
}
}
}
}
// ---- PRIORITY 2: NAVIGATE (carrying) ----
if (!acted && isCarrying == 1.) {
if (int(cargoMaterialId) == MAT_FOOD) {
// follow toHome pheromone
float sAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), 1.);
float sLeft = smell(applyOffsetToPos(pos, vec2(cos(angle - ANT_ROTATION_ANGLE), sin(angle - ANT_ROTATION_ANGLE)) * sampleDistance), 1.);
float sRight = smell(applyOffsetToPos(pos, vec2(cos(angle + ANT_ROTATION_ANGLE), sin(angle + ANT_ROTATION_ANGLE)) * sampleDistance), 1.);
if (sLeft > sAhead && sLeft > sRight) {
angle -= ANT_ROTATION_ANGLE;
} else if (sRight > sAhead && sRight > sLeft) {
angle += ANT_ROTATION_ANGLE;
}
} else {
// carrying powder: bias upward toward surface
#if VIEW_MODE_SIDE
float upwardBias = PI * 0.5;
float angleDiff = upwardBias - angle;
angleDiff = mod(angleDiff + PI, 2.0 * PI) - PI; angleDiff = mod(angleDiff + PI, 2.0 * PI) - PI;
// gentle steering toward down (20% blend) angle += angleDiff * 0.3;
angle += angleDiff * 0.2; #endif
}
// add wander noise to carrying ants too
float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2);
if (noise2 > 0.5) {
angle += ANT_ROTATION_ANGLE;
} else {
angle -= ANT_ROTATION_ANGLE;
}
acted = true;
}
// ---- PRIORITY 3: FORAGE (not carrying, food scent detected) ----
if (!acted && isCarrying == 0.) {
float sAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), 0.);
float sLeft = smell(applyOffsetToPos(pos, vec2(cos(angle - ANT_ROTATION_ANGLE), sin(angle - ANT_ROTATION_ANGLE)) * sampleDistance), 0.);
float sRight = smell(applyOffsetToPos(pos, vec2(cos(angle + ANT_ROTATION_ANGLE), sin(angle + ANT_ROTATION_ANGLE)) * sampleDistance), 0.);
float maxSmell = max(sAhead, max(sLeft, sRight));
if (maxSmell > SCENT_THRESHOLD) {
if (sLeft > sAhead && sLeft > sRight) {
angle -= ANT_ROTATION_ANGLE;
} else if (sRight > sAhead && sRight > sLeft) {
angle += ANT_ROTATION_ANGLE;
}
acted = true;
}
}
// ---- PRIORITY 4: DIG (not carrying, diggable material nearby below surface) ----
#if VIEW_MODE_SIDE
if (!acted && isCarrying == 0.) {
vec2 belowUv = roundUvToCellCenter(pos - vec2(0., cellSize));
float belowMat2 = texture(tWorld, belowUv).x;
vec4 belowProps2 = texelFetch(uMaterialProps, ivec2(int(belowMat2), 0), 0);
// suppress digging if on the surface (air above) — don't dig topsoil into sky
vec2 aboveUv = roundUvToCellCenter(pos + vec2(0., cellSize));
float aboveMat2 = texture(tWorld, aboveUv).x;
bool onSurfaceTop = (int(aboveMat2) == MAT_AIR);
if (!onSurfaceTop
&& belowProps2.r == BEHAVIOR_POWDER
&& belowProps2.b <= ANT_CARRY_STRENGTH) {
// bias angle toward ~40 degrees below horizontal (angle of repose)
float targetAngle = (cos(angle) >= 0.)
? -0.7 // ~-40 degrees (right and down)
: -(PI - 0.7); // ~-(180-40) degrees (left and down)
float angleDiff2 = targetAngle - angle;
angleDiff2 = mod(angleDiff2 + PI, 2.0 * PI) - PI;
angle += angleDiff2 * 0.2;
acted = true;
} }
} }
#endif #endif
float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2); // ---- PRIORITY 5: WANDER (fallback) ----
if (!acted) {
float sampleAhead = smell(applyOffsetToPos(pos, vec2(cos(angle), sin(angle)) * sampleDistance), isCarrying); if (noise < 0.33) {
float sampleLeft = smell(applyOffsetToPos(pos, vec2(cos(angle - ANT_ROTATION_ANGLE), sin(angle - ANT_ROTATION_ANGLE)) * sampleDistance), isCarrying); angle += ANT_ROTATION_ANGLE;
float sampleRight = smell(applyOffsetToPos(pos, vec2(cos(angle + ANT_ROTATION_ANGLE), sin(angle + ANT_ROTATION_ANGLE)) * sampleDistance), isCarrying);
if (sampleAhead > sampleLeft && sampleAhead > sampleRight) {
// don't change direction
} else if (sampleLeft > sampleAhead && sampleLeft > sampleRight) {
angle -= ANT_ROTATION_ANGLE; // steer left
} else if (sampleRight > sampleAhead && sampleRight > sampleLeft) {
angle += ANT_ROTATION_ANGLE; // steer right
} else if (noise < 0.33) {
angle += ANT_ROTATION_ANGLE; // no smell detected, do random movement
} else if (noise < 0.66) { } else if (noise < 0.66) {
angle -= ANT_ROTATION_ANGLE; angle -= ANT_ROTATION_ANGLE;
} }
float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2);
if (noise2 > 0.5) { if (noise2 > 0.5) {
angle += ANT_ROTATION_ANGLE * 2.; angle += ANT_ROTATION_ANGLE * 2.;
} else { } else {
@ -237,6 +323,7 @@ void main() {
} }
} }
// ---- MOVEMENT APPLICATION ----
vec2 offset = vec2(cos(angle), sin(angle)); vec2 offset = vec2(cos(angle), sin(angle));
pos = applyOffsetToPos(pos, offset); pos = applyOffsetToPos(pos, offset);
@ -244,54 +331,37 @@ void main() {
angle += PI * (noise - 0.5); angle += PI * (noise - 0.5);
} }
// ---- PICKUP (check cell ahead — ants walk in air, can't enter solid cells) ----
if (isCarrying == 0.) { if (isCarrying == 0.) {
float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x; vec2 aheadUv = roundUvToCellCenter(pos + vec2(cos(angle), sin(angle)) * cellSize);
int cellMatInt = int(cellMatId); float aheadMatId = texture(tWorld, aheadUv).x;
int aheadMatInt = int(aheadMatId);
if (cellMatInt == MAT_FOOD) { // food: pick up from any adjacent cell
// food pickup (existing foraging behavior) if (aheadMatInt == MAT_FOOD) {
isCarrying = 1.; isCarrying = 1.;
cargoMaterialId = cellMatId; cargoMaterialId = aheadMatId;
angle += PI; angle += PI;
storage = getMaxScentStorage(vUv); storage = getMaxScentStorage(vUv);
} else if (cellMatInt != MAT_AIR && cellMatInt != MAT_HOME) { } else if (aheadMatInt != MAT_AIR && aheadMatInt != MAT_HOME) {
// check if diggable powder material // powder: don't grab the surface we're walking on — only dig
vec4 props = texelFetch(uMaterialProps, ivec2(cellMatInt, 0), 0); // when facing diagonally into material (DIG priority angles us there)
vec2 belowCell = roundUvToCellCenter(pos - vec2(0., cellSize));
bool isWalkingSurface = all(equal(aheadUv, belowCell));
if (!isWalkingSurface) {
vec4 props = texelFetch(uMaterialProps, ivec2(aheadMatInt, 0), 0);
float behavior = props.r; float behavior = props.r;
float hardness = props.b; float hardness = props.b;
if (behavior == BEHAVIOR_POWDER && hardness <= ANT_CARRY_STRENGTH) { if (behavior == BEHAVIOR_POWDER && hardness <= ANT_CARRY_STRENGTH) {
isCarrying = 1.; isCarrying = 1.;
cargoMaterialId = cellMatId; cargoMaterialId = aheadMatId;
angle += PI; angle += PI;
storage = getMaxScentStorage(vUv); storage = getMaxScentStorage(vUv);
} }
} }
} }
if (tryDropFood(pos)) {
storage = getMaxScentStorage(vUv);
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);
}
}
} }
} // end !isFalling
FragColor = vec4( FragColor = vec4(
pos.x, pos.x,

View file

@ -8,6 +8,12 @@ out vec4 FragColor;
uniform sampler2D map; uniform sampler2D map;
uniform sampler2D uMaterialColors; uniform sampler2D uMaterialColors;
float hash(ivec2 p) {
int h = p.x * 374761393 + p.y * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return float(h & 0x7fffffff) / float(0x7fffffff);
}
void main() { void main() {
vec4 value = texture(map, vUv); vec4 value = texture(map, vUv);
@ -25,12 +31,15 @@ void main() {
if (a == 0.) t = vec3(0); if (a == 0.) t = vec3(0);
vec3 color = mix(vec3(1, 1, 1), t, a * 0.7); vec3 color = mix(vec3(0.53, 0.81, 0.92), t, a * 0.7);
// non-air cells use material color as base, with pheromone tint // non-air cells use material color as base, with pheromone tint
if (materialId != MAT_AIR) { if (materialId != MAT_AIR) {
vec4 matColor = texelFetch(uMaterialColors, ivec2(materialId, 0), 0); vec4 matColor = texelFetch(uMaterialColors, ivec2(materialId, 0), 0);
color = mix(matColor.rgb, t, a * 0.3); // per-pixel color variation: +/-4% brightness noise
float colorNoise = hash(ivec2(gl_FragCoord.xy)) * 0.08 - 0.04;
vec3 variedColor = matColor.rgb + colorNoise;
color = mix(variedColor, t, a * 0.3);
} }
FragColor = vec4(color, 1); FragColor = vec4(color, 1);