Compare commits
No commits in common. "ant-behavior-overhaul" and "main" have entirely different histories.
ant-behavi
...
main
15 changed files with 292 additions and 646 deletions
41
CLAUDE.md
41
CLAUDE.md
|
|
@ -1,6 +1,6 @@
|
|||
# ants-simulation
|
||||
|
||||
GPU-accelerated ant colony simulation. ants navigate via pheromone trails, all computed in GLSL fragment shaders rendered to offscreen textures. uses WebGL2 features (MRT, GLSL3). 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue