Compare commits

..

No commits in common. "ant-behavior-overhaul" and "main" have entirely different histories.

15 changed files with 292 additions and 646 deletions

View file

@ -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). 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).
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
@ -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
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); gravity pass runs first (ants fall through air), then priority-stack brain for active ants; handles collision displacement out of solid cells
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, active/budget counts), feeds back as uniforms
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); passes ant spawn position to AntsComputeScene
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 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):
**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, cargoMaterialId, pathIntDx, pathIntDy]`
@ -72,41 +72,22 @@ 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
- `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
- `src/Renderer.ts` — render target creation, pass orchestration, MRT setup, colony stats readback
- `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); `BRUSH_ANTS = 999` sentinel for ant-spawn brush
- `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); ant count shown as "active / budget"
- `src/__tests__/worldInit.test.ts` — tests for world seeding (home/food placement via mulberry32 PRNG)
- `src/Config.ts` — simulation parameters (per-channel pheromone configs)
- `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); gravity, priority stack, spawn activation
- `src/shaders/antsCompute.frag` — ant behavior + MRT output (2 render targets via layout qualifiers)
- `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
- `src/shaders/screenWorld.frag` — final composite; applies per-pixel hash noise (+/-4% brightness) to material colors
## planning docs

View file

@ -1,161 +0,0 @@
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,9 +2,7 @@ const STORAGE_KEY = "ants-simulation-config";
const defaults = {
worldSize: 1024,
antsStartCount: 4,
antBudget: 512,
seedWorld: true,
antsCount: 12,
simulationStepsPerSecond: 60,
scentThreshold: 0.01,
scentFadeOutFactor: 0.001,

View file

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

View file

@ -47,10 +47,6 @@ export default class Renderer {
constructor(public canvas: HTMLCanvasElement) {
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();
@ -76,7 +72,7 @@ export default class Renderer {
}
private initResources() {
const antTextureSize = Math.ceil(Math.sqrt(Config.antBudget));
const antTextureSize = Math.round(Math.sqrt(2 ** Config.antsCount));
this.resources = {
worldRenderTarget: new THREE.WebGLRenderTarget(
@ -185,7 +181,6 @@ export default class Renderer {
);
scenes.sandPhysics.material.uniforms.uFrame.value = this.frameCounter;
this.renderer.render(scenes.sandPhysics, scenes.sandPhysics.camera);
this.frameCounter++;
this.setViewportFromRT(this.resources.worldBlurredRenderTarget);
@ -211,11 +206,6 @@ export default class Renderer {
this.resources.antsPresenceRenderTarget.texture;
scenes.ants.material.uniforms.uMaterialProps.value =
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.setViewportFromRT(this.resources.antsDiscreteRenderTarget);
@ -331,13 +321,11 @@ export default class Renderer {
BEHAVIOR_GAS: Renderer.convertNumberToFloatString(BEHAVIOR_GAS),
BEHAVIOR_SOLID: Renderer.convertNumberToFloatString(BEHAVIOR_SOLID),
VIEW_MODE_SIDE: Config.viewMode === "side" ? "1" : "0",
ANTS_START_COUNT: String(Config.antsStartCount),
ANT_BUDGET: String(Config.antBudget),
};
}
public reset(scenes: SceneCollection) {
const antTextureSize = Math.ceil(Math.sqrt(Config.antBudget));
const antTextureSize = Math.round(Math.sqrt(2 ** Config.antsCount));
this.resources.worldRenderTarget.setSize(
Config.worldSize,
@ -347,10 +335,7 @@ export default class Renderer {
this.renderer.clear();
if (Config.viewMode === "side") {
const data = generateSideViewWorld(
Config.worldSize,
Config.seedWorld,
);
const data = generateSideViewWorld(Config.worldSize);
const initTexture = new THREE.DataTexture(
data,
Config.worldSize,

View file

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

View file

@ -1,20 +1,6 @@
import { MAT_FOOD, MAT_HOME, MAT_SAND } from "./constants";
import { MAT_HOME, MAT_SAND } from "./constants";
// 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 {
export function generateSideViewWorld(worldSize: number): Float32Array {
const data = new Float32Array(worldSize * worldSize * 4);
const sandHeight = Math.floor(worldSize * 0.6);
const surfaceRow = sandHeight - 1;
@ -28,23 +14,10 @@ export function generateSideViewWorld(
}
// top 40% stays MAT_AIR (Float32Array is zero-initialized)
if (seed) {
const rng = mulberry32(Date.now());
// 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;
// place home on surface near center
const centerX = Math.floor(worldSize / 2);
const homeIdx = (surfaceRow * worldSize + centerX) * 4;
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;
}

View file

@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test";
import { defaults } from "../Config";
import {
MAT_AIR,
MAT_DIRT,
@ -32,22 +31,3 @@ 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,78 +1,87 @@
import { describe, expect, test } from "bun:test";
import { MAT_AIR, MAT_FOOD, MAT_HOME, MAT_SAND } from "../constants";
import { MAT_AIR, MAT_HOME, MAT_SAND } from "../constants";
import { generateSideViewWorld } from "../WorldInit";
const SIZE = 64;
describe("generateSideViewWorld", () => {
const worldSize = 64;
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("output length is worldSize * worldSize * 4", () => {
const data = generateSideViewWorld(SIZE);
expect(data.length).toBe(SIZE * SIZE * 4);
});
test("top 40% is air", () => {
const data = generateSideViewWorld(worldSize, true);
const sandHeight = Math.floor(worldSize * 0.6);
const airY = sandHeight + 5;
const idx = (airY * worldSize + 10) * 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("seed=true places exactly one home and one food on surface", () => {
const data = generateSideViewWorld(worldSize, true);
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++;
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);
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("seed=true places home and food on the surface row", () => {
const data = generateSideViewWorld(worldSize, true);
const sandHeight = Math.floor(worldSize * 0.6);
const surfaceRow = sandHeight - 1;
let homeY = -1;
let foodY = -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) homeY = y;
if (data[idx] === MAT_FOOD) foodY = y;
}
}
expect(homeY).toBe(surfaceRow);
expect(foodY).toBe(surfaceRow);
});
test("no food placed on the surface row", () => {
const data = generateSideViewWorld(SIZE);
const surfaceRow = Math.floor(SIZE * 0.6) - 1;
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++;
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);
}
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",
behavior: BEHAVIOR_GAS,
density: 0,
color: [0.53, 0.81, 0.92, 1.0],
color: [0, 0, 0, 0],
hardness: 0,
angleOfRepose: 0,
},

View file

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

View file

@ -15,49 +15,29 @@ import fragmentShaderGround from "../shaders/screenWorld.frag";
import vertexShaderGround from "../shaders/screenWorld.vert";
import AbstractScene from "./AbstractScene";
export const BRUSH_ANTS = 999;
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 readonly antSpawnRequest: THREE.Vector4 = new THREE.Vector4(
0,
0,
0,
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 === BRUSH_ANTS) return -1;
if (this.drawMode >= 0) return this.drawMode;
if (this.isPointerDown && Config.brushMaterial >= 0) {
if (Config.brushMaterial === BRUSH_ANTS) return -1;
if (this.isPointerDown && Config.brushMaterial >= 0)
return Config.brushMaterial;
}
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 renderHeight: number = 1;
constructor(renderer: Renderer) {
super(renderer);
const groundDefines = this.renderer.getCommonMaterialDefines();
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.ShaderMaterial({
@ -72,7 +52,7 @@ export default class ScreenScene extends AbstractScene {
},
vertexShader: vertexShaderGround,
fragmentShader: fragmentShaderGround,
defines: groundDefines,
defines: this.renderer.getCommonMaterialDefines(),
glslVersion: THREE.GLSL3,
}),
);
@ -101,7 +81,6 @@ export default class ScreenScene extends AbstractScene {
vertexShader: vertexShaderAnts,
fragmentShader: fragmentShaderAnts,
transparent: true,
glslVersion: THREE.GLSL3,
});
this.createInstancedAntsMesh();
@ -203,10 +182,6 @@ export default class ScreenScene extends AbstractScene {
this.drawMode = MAT_DIRT;
break;
}
case "KeyA": {
this.drawMode = BRUSH_ANTS;
break;
}
case "KeyV": {
Config.viewMode =
Config.viewMode === "side" ? "top" : "side";
@ -295,16 +270,7 @@ export default class ScreenScene extends AbstractScene {
this.renderHeight = height;
}
public update() {
if (this.isAntBrushActive && this.isPointerDown) {
this.antSpawnRequest.set(
this.pointerPosition.x,
this.pointerPosition.y,
Config.brushRadius,
1, // flag: spawn requested
);
}
}
public update() {}
public applyCameraZoom() {
this.updateCameraZoom();

View file

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

View file

@ -15,7 +15,6 @@ uniform sampler2D tWorld;
uniform sampler2D tPresence;
uniform float uForagerRatio;
uniform sampler2D uMaterialProps;
uniform vec4 uAntSpawn;
const float ANT_CARRY_STRENGTH = 1.0;
const float sampleDistance = 20.;
@ -34,17 +33,22 @@ vec2 roundUvToCellCenter(vec2 uv) {
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) {
float currentMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
float belowMatId = texture(tWorld, roundUvToCellCenter(pos - vec2(0., cellSize))).x;
return int(currentMatId) == MAT_HOME || int(belowMatId) == MAT_HOME;
float materialId = texture(tWorld, roundUvToCellCenter(pos)).x;
return int(materialId) == MAT_HOME;
}
bool isObstacle(vec2 pos) {
float materialId = texture(tWorld, roundUvToCellCenter(pos)).x;
int matInt = int(materialId);
// ants can only move through air and home cells
return matInt != MAT_AIR && matInt != MAT_HOME;
return int(materialId) == MAT_ROCK;
}
float smell(vec2 pos, float isCarrying) {
@ -90,48 +94,9 @@ void main() {
float pathIntDx = lastExtState.b;
float pathIntDy = lastExtState.a;
// 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));
bool movementProcessed = false;
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
// spawn on sand surface: random X, scan down from top to find first non-air cell
float spawnX = rand(vUv * 10000.);
@ -145,7 +110,7 @@ void main() {
}
}
pos = vec2(spawnX, surfaceY);
angle = (rand(vUv * 31337.) > 0.5) ? 0.0 : PI; // face randomly left or right
angle = -PI * 0.5; // face downward initially
#else
pos = vec2(0.5);
angle = rand(vUv * 10000.) * 2. * PI;
@ -157,165 +122,114 @@ void main() {
pathIntDx = 0.;
pathIntDy = 0.;
}
}
// --- GRAVITY AND SURFACE CHECK ---
vec2 cellCenter = roundUvToCellCenter(pos);
float belowMat = texture(tWorld, cellCenter - vec2(0., cellSize)).x;
float currentMat = texture(tWorld, cellCenter).x;
bool belowSolid = (int(belowMat) != MAT_AIR);
// collision displacement: if current cell is now solid (sand fell on us), push to nearest air
if (int(currentMat) != MAT_AIR && int(currentMat) != MAT_HOME) {
vec2 upPos = cellCenter + vec2(0., cellSize);
vec2 leftPos = cellCenter - vec2(cellSize, 0.);
vec2 rightPos = cellCenter + vec2(cellSize, 0.);
if (int(texture(tWorld, upPos).x) == MAT_AIR) {
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;
// carrying food and at home -> drop food
if (int(cargoMaterialId) == MAT_FOOD && tryDropFood(pos)) {
isCarrying = 0.;
cargoMaterialId = 0.;
angle += PI;
storage = getMaxScentStorage(vUv);
acted = true;
}
// carrying powder and on surface (air cell, solid below) -> deposit
if (!acted && int(cargoMaterialId) != MAT_FOOD) {
if (int(cellMatId) == MAT_AIR) {
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;
angle += angleDiff * 0.3;
#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
// ---- PRIORITY 5: WANDER (fallback) ----
if (!acted) {
if (isCarrying == 0.) {
if (noise < 0.33) {
angle += ANT_ROTATION_ANGLE;
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) {
#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);
float sampleLeft = smell(applyOffsetToPos(pos, vec2(cos(angle - ANT_ROTATION_ANGLE), sin(angle - ANT_ROTATION_ANGLE)) * sampleDistance), isCarrying);
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) {
angle -= ANT_ROTATION_ANGLE;
}
float noise2 = rand(vUv * 1000. + fract(uTime / 1000.) + 0.2);
if (noise2 > 0.5) {
angle += ANT_ROTATION_ANGLE * 2.;
} else {
@ -323,7 +237,6 @@ void main() {
}
}
// ---- MOVEMENT APPLICATION ----
vec2 offset = vec2(cos(angle), sin(angle));
pos = applyOffsetToPos(pos, offset);
@ -331,37 +244,54 @@ void main() {
angle += PI * (noise - 0.5);
}
// ---- PICKUP (check cell ahead — ants walk in air, can't enter solid cells) ----
if (isCarrying == 0.) {
vec2 aheadUv = roundUvToCellCenter(pos + vec2(cos(angle), sin(angle)) * cellSize);
float aheadMatId = texture(tWorld, aheadUv).x;
int aheadMatInt = int(aheadMatId);
float cellMatId = texture(tWorld, roundUvToCellCenter(pos)).x;
int cellMatInt = int(cellMatId);
// food: pick up from any adjacent cell
if (aheadMatInt == MAT_FOOD) {
if (cellMatInt == MAT_FOOD) {
// food pickup (existing foraging behavior)
isCarrying = 1.;
cargoMaterialId = aheadMatId;
cargoMaterialId = cellMatId;
angle += PI;
storage = getMaxScentStorage(vUv);
} else if (aheadMatInt != MAT_AIR && aheadMatInt != MAT_HOME) {
// powder: don't grab the surface we're walking on — only dig
// 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);
} 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 = aheadMatId;
cargoMaterialId = cellMatId;
angle += PI;
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(
pos.x,

View file

@ -8,12 +8,6 @@ out vec4 FragColor;
uniform sampler2D map;
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() {
vec4 value = texture(map, vUv);
@ -31,15 +25,12 @@ void main() {
if (a == 0.) t = vec3(0);
vec3 color = mix(vec3(0.53, 0.81, 0.92), t, a * 0.7);
vec3 color = mix(vec3(1, 1, 1), t, a * 0.7);
// non-air cells use material color as base, with pheromone tint
if (materialId != MAT_AIR) {
vec4 matColor = texelFetch(uMaterialColors, ivec2(materialId, 0), 0);
// 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);
color = mix(matColor.rgb, t, a * 0.3);
}
FragColor = vec4(color, 1);