From 6fb46573da9850fb50d4906a833b2dfdb0027862 Mon Sep 17 00:00:00 2001 From: Errilaz Date: Tue, 4 Aug 2026 23:03:55 +0200 Subject: [PATCH] feat: foilage --- AGENTS.md | 31 +++++++------ app/assets.ts | 7 ++- app/level.ts | 75 +++++++++++++++++++++++++++++--- app/main.ts | 1 + assets/flower.png | Bin 0 -> 4205 bytes engine/render/RenderConfig.ts | 1 + engine/scene/Bush.ts | 76 ++++++++++++++++++++++++++++++++ engine/scene/Flower.ts | 79 ++++++++++++++++++++++++++++++++++ scripts/gen-assets.ts | 19 ++++++++ 9 files changed, 270 insertions(+), 19 deletions(-) create mode 100644 assets/flower.png create mode 100644 engine/scene/Bush.ts create mode 100644 engine/scene/Flower.ts diff --git a/AGENTS.md b/AGENTS.md index 0584638..fbe377c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,9 @@ rules live in `.agents/rules/*.md`. (procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob; `Tree.build` appends into shared trunk + foliage meshes), `Boulder` (procedural low-poly rock: a squashed, jittered, part-buried sphere; - `Boulder.build` appends into a shared mesh). + `Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf + blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom; + samples a 2x2 color-atlas texture, drawn double-sided). - `app/` — browser glue. - `main.ts` — game loop, input, preset switching, canvas blit, FPS meter. - `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes @@ -77,12 +79,13 @@ rules live in `.agents/rules/*.md`. - `level.ts` — builds the playground: a flat stone-floored room (three thick walls via `slab`, north side open) always drawn, in the center of a big grassy `Terrain` world (~20x across). Props are placed first (`placeTrees` / - `placeBoulders` → instance lists + colliders; `TREE_/BOULDER_COUNT`/`_SEED`/ - `_REACH`), then `buildChunks` bakes terrain + props into a `CHUNK_GRID` x - `CHUNK_GRID` grid of `Chunk`s (each = per-texture meshes grass/bark/leaf/needle/ - rock + a tight AABB) that `main` frustum-culls. `Aabb` colliders (walls, crate, - grown trunks, big boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, - sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it + `placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders; + `TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`), then `buildChunks` bakes + terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = + per-texture meshes grass/bark/leaf/needle/rock/flowers + a tight AABB) that + `main` frustum-culls; bushes fold into the leaf mesh, flowers get their own + (drawn double-sided). `Aabb` colliders (walls, crate, grown trunks, big + boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it stays clean over the terrain skirt that laps under the room edges. Room surfaces are single flat quads -- no subdivision needed since texturing is perspective-correct. @@ -94,9 +97,10 @@ rules live in `.agents/rules/*.md`. `#fps` meter div (styled inline). - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`. -- `assets/` — generated `floor/grass/bark/leaf/needle/rock/wall/crate/npc` PNGs - (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = tree - trunk/oak/spruce, `rock` = boulders). Swap for real art anytime; +- `assets/` — generated `floor/grass/bark/leaf/needle/rock/flower/wall/crate/npc` + PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = + tree trunk/oak/spruce, `rock` = boulders, `flower` = 2x2 bloom-color atlas). + Swap for real art anytime; filenames are the contract. - `server/` — Bun server stub. `shared/` — isomorphic slot. @@ -106,7 +110,8 @@ rules live in `.agents/rules/*.md`. `Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`, then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock, -bark, leaf, needle (backface-culled) → `Sprite.billboard(npc)` (double-sided) → +bark, leaf, needle (backface-culled) + flowers (double-sided) → +`Sprite.billboard(npc)` (double-sided) → `Framebuffer.quantize` → `present` (integer-scale, letterboxed blit; `imageSmoothingEnabled` follows `upscaleFilter`). @@ -190,7 +195,9 @@ canopy blobs (oak) / tiers (spruce); `seed` gives each tree its own wobble. appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it never cracks) into one shared rock mesh, sunk partway into the ground. `scatterBoulders` sizes them small→big (biased small) and drops colliders on the -big ones. +big ones. `Bush` (leaf-blob clusters) and `Flower` (stem + colored bloom, atlas +UVs) are the same again — ground detail scattered near the play area, no +colliders; add a new prop type by cloning the pattern (generator + scatter). ## Controls diff --git a/app/assets.ts b/app/assets.ts index 57619be..3fc376a 100644 --- a/app/assets.ts +++ b/app/assets.ts @@ -2,6 +2,7 @@ import type { Texture } from "../engine/render/Texture" import barkUrl from "../assets/bark.png" import crateUrl from "../assets/crate.png" import floorUrl from "../assets/floor.png" +import flowerUrl from "../assets/flower.png" import grassUrl from "../assets/grass.png" import leafUrl from "../assets/leaf.png" import needleUrl from "../assets/needle.png" @@ -16,6 +17,7 @@ export type Textures = { leaf: Texture needle: Texture rock: Texture + flower: Texture wall: Texture crate: Texture npc: Texture @@ -23,18 +25,19 @@ export type Textures = { /** Load every game texture up front. Call once before starting the loop. */ export async function loadTextures(): Promise { - const [floor, grass, bark, leaf, needle, rock, wall, crate, npc] = await Promise.all([ + const [floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc] = await Promise.all([ loadTexture(floorUrl), loadTexture(grassUrl), loadTexture(barkUrl), loadTexture(leafUrl), loadTexture(needleUrl), loadTexture(rockUrl), + loadTexture(flowerUrl), loadTexture(wallUrl), loadTexture(crateUrl), loadTexture(npcUrl), ]) - return { floor, grass, bark, leaf, needle, rock, wall, crate, npc } + return { floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc } } function loadTexture(url: string): Promise { diff --git a/app/level.ts b/app/level.ts index 5dd01c2..6471f3b 100644 --- a/app/level.ts +++ b/app/level.ts @@ -2,6 +2,8 @@ import { Color } from "../engine/render/Color" import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import { STRIDE, type Mesh } from "../engine/scene/Mesh" import { Boulder } from "../engine/scene/Boulder" +import { Bush } from "../engine/scene/Bush" +import { Flower, type FlowerColor } from "../engine/scene/Flower" import { Terrain } from "../engine/scene/Terrain" import { Tree } from "../engine/scene/Tree" @@ -35,6 +37,8 @@ export type Chunk = { leaf: Mesh needle: Mesh rock: Mesh + /** Flowers (color-atlas texture); drawn double-sided, so kept separate. */ + flowers: Mesh } /** The playground: a flat-floored room dropped into the center of a big open @@ -91,6 +95,16 @@ const BOULDER_COUNT = 70 const BOULDER_SEED = 0xB0142 const BOULDER_REACH = 0.7 +/** Bushes + flowers: ground detail, kept to the nearer band since they're small + * and fog/size hides them far out. Flowers roll white/red/yellow. */ +const BUSH_COUNT = 140 +const BUSH_SEED = 0xB554 +const BUSH_REACH = 0.35 +const FLOWER_COUNT = 340 +const FLOWER_SEED = 0xF10E +const FLOWER_REACH = 0.3 +const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"] + /** Spatial partition of the world for frustum culling: `CHUNK_GRID` x * `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter * (less drawn off-screen) but cost more per-cell tests + bounds; this is the @@ -175,15 +189,18 @@ export function buildLevel(): Level { // frustum-cullable spatial chunks. const trees = placeTrees(colliders) const boulders = placeBoulders(colliders) - const chunks = buildChunks(trees, boulders) + const bushes = placeBushes() + const flowers = placeFlowers() + const chunks = buildChunks(trees, boulders, bushes, flowers) return { floor, walls, crate, chunks, colliders, npcPosition, terrain: TERRAIN, sky } } /** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial * chunks. Each prop lands in the cell holding its base; the cell's bounds are - * grown to the real geometry so overhanging canopies never get culled early. */ -function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] { + * grown to the real geometry so overhanging canopies never get culled early. + * Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */ +function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] { const cell = (TERRAIN.outer * 2) / CHUNK_GRID const chunks: Chunk[] = [] for (let ci = 0; ci < CHUNK_GRID; ci++) { @@ -197,6 +214,7 @@ function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] { const leaf = mesh() const needle = mesh() const rock = mesh() + const flowerMesh = mesh() Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV) for (const tree of trees) { if (inCell(tree.position, x0, z0, x1, z1)) { @@ -208,11 +226,21 @@ function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] { Boulder.build(boulder, rock) } } - const b = bounds([grass, bark, leaf, needle, rock]) + for (const bush of bushes) { + if (inCell(bush.position, x0, z0, x1, z1)) { + Bush.build(bush, leaf) + } + } + for (const flower of flowers) { + if (inCell(flower.position, x0, z0, x1, z1)) { + Flower.build(flower, flowerMesh) + } + } + const b = bounds([grass, bark, leaf, needle, rock, flowerMesh]) if (b === null) { continue } - chunks.push({ ...b, grass, bark, leaf, needle, rock }) + chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh }) } } return chunks @@ -302,6 +330,43 @@ function placeBoulders(colliders: Aabb[]): Boulder[] { return boulders } +/** Scatter bushes on the grass near the play area (no colliders -- walk through). */ +function placeBushes(): Bush[] { + const rand = mulberry(BUSH_SEED) + const maxDist = TERRAIN.outer * BUSH_REACH + const bushes: Bush[] = [] + for (let guard = 0; bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20; guard++) { + const angle = rand() * Math.PI * 2 + const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3) + const x = Math.cos(angle) * dist + const z = Math.sin(angle) * dist + if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) { + continue + } + bushes.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, size: 0.8 + rand() * 1, seed: (rand() * 0xFFFFFFFF) | 0 }) + } + return bushes +} + +/** Scatter small flowers on the grass near the play area, colors rolled. */ +function placeFlowers(): Flower[] { + const rand = mulberry(FLOWER_SEED) + const maxDist = TERRAIN.outer * FLOWER_REACH + const flowers: Flower[] = [] + for (let guard = 0; flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20; guard++) { + const angle = rand() * Math.PI * 2 + const dist = ARENA + 2 + rand() * (maxDist - ARENA - 2) + const x = Math.cos(angle) * dist + const z = Math.sin(angle) * dist + if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 1) { + continue + } + const color = FLOWER_COLORS[(rand() * FLOWER_COLORS.length) | 0] + flowers.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, color, size: 0.28 + rand() * 0.22, seed: (rand() * 0xFFFFFFFF) | 0 }) + } + return flowers +} + /** Deterministic 0..1 generator (mulberry32) for tree placement. */ function mulberry(seed: number): () => number { let a = seed >>> 0 diff --git a/app/main.ts b/app/main.ts index d3becd6..8a9e195 100644 --- a/app/main.ts +++ b/app/main.ts @@ -128,6 +128,7 @@ async function main(): Promise { Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true) Rasterizer.draw(fb, c.leaf, textures.leaf, viewProj, config, true) Rasterizer.draw(fb, c.needle, textures.needle, viewProj, config, true) + Rasterizer.draw(fb, c.flowers, textures.flower, viewProj, config) } Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config) Framebuffer.quantize(fb, config) diff --git a/assets/flower.png b/assets/flower.png new file mode 100644 index 0000000000000000000000000000000000000000..b5658f3fde0badc3071580600d128f0cb7b2cbb8 GIT binary patch literal 4205 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 DZlLWu literal 0 HcmV?d00001 diff --git a/engine/render/RenderConfig.ts b/engine/render/RenderConfig.ts index 00b4b9f..1d252ba 100644 --- a/engine/render/RenderConfig.ts +++ b/engine/render/RenderConfig.ts @@ -94,6 +94,7 @@ export namespace RenderConfig { colorDepth: 8, dither: 0, vertexSnap: 0, + perspectiveCorrect: 1, textureFilter: "linear", lighting: "flat", fog: null, diff --git a/engine/scene/Bush.ts b/engine/scene/Bush.ts new file mode 100644 index 0000000..c55442d --- /dev/null +++ b/engine/scene/Bush.ts @@ -0,0 +1,76 @@ +import type { Vec3 } from "../math/Vec3" +import { STRIDE, type Mesh } from "./Mesh" + +const TAU = Math.PI * 2 + +/** A low shrub: a tight cluster of small leafy blobs sitting on the ground. + * Textured with the same leaf sheet as oak canopies, so it batches into the + * chunk's foliage mesh. `size` is the overall spread; `seed` the per-bush wobble. */ +export type Bush = { + position: Vec3 + size: number + seed: number +} + +/** Low-poly bush geometry, same faceted flat-shaded style as the trees. A few + * overlapping jittered spheres read as a rounded shrub; blobs are closed and + * wound outward, so backface culling is safe. `build` appends into a shared + * (leaf-textured) mesh. */ +export namespace Bush { + export function build(bush: Bush, mesh: Mesh): void { + const rand = rng(bush.seed) + // A handful of smaller overlapping lumps reads as a soft shrub; one big + // sphere reads as a boulder. + const blobs = 3 + Math.floor(rand() * 3) + const r = bush.size * 0.42 + for (let i = 0; i < blobs; i++) { + const angle = rand() * TAU + const dist = i === 0 ? 0 : bush.size * 0.5 * rand() + const cx = bush.position.x + Math.cos(angle) * dist + const cz = bush.position.z + Math.sin(angle) * dist + const cy = bush.position.y + r * (0.5 + rand() * 0.4) + blob(mesh, cx, cy, cz, r * (0.55 + rand() * 0.3), rand) + } + } + + /** A small lumpy low-poly sphere, wound outward (matches the oak canopy blob). */ + function blob(mesh: Mesh, cx: number, cy: number, cz: number, radius: number, rand: () => number): void { + const seg = 6 + const rings = 4 + const start = mesh.verts.length / STRIDE + for (let r = 0; r <= rings; r++) { + const phi = (r / rings) * Math.PI + const cyv = Math.cos(phi) + const crv = Math.sin(phi) + const scale = radius * (0.9 + rand() * 0.18) + for (let s = 0; s <= seg; s++) { + const theta = (s / seg) * TAU + mesh.verts.push( + cx + crv * Math.cos(theta) * scale, + cy + cyv * scale, + cz + crv * Math.sin(theta) * scale, + (s / seg) * 2, + (r / rings) * 2, + ) + } + } + const row = seg + 1 + for (let r = 0; r < rings; r++) { + for (let s = 0; s < seg; s++) { + const p = start + r * row + s + mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) + } + } + } + + /** Deterministic 0..1 generator (mulberry32) seeded per bush. */ + function rng(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6D2B79F5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } +} diff --git a/engine/scene/Flower.ts b/engine/scene/Flower.ts new file mode 100644 index 0000000..e154e8b --- /dev/null +++ b/engine/scene/Flower.ts @@ -0,0 +1,79 @@ +import type { Vec3 } from "../math/Vec3" +import { Mesh } from "./Mesh" + +const TAU = Math.PI * 2 + +/** Flower bloom color, indexing a region of the `flower` texture atlas. */ +export type FlowerColor = "white" | "red" | "yellow" + +/** A single small flower: a thin crossed-quad stem plus a shallow fan of petals. + * Tiny, so it is drawn double-sided (no backface cull) and carries no collider. + * `size` is roughly its height; `seed` jitters the petals. */ +export type Flower = { + position: Vec3 + color: FlowerColor + size: number + seed: number +} + +/** + * Low-poly flower geometry. The `flower` texture is a 2x2 color atlas -- green + * (stem) plus white / red / yellow blooms -- and every vertex samples the flat + * center of one region, so a flower is solid-colored with no per-flower texture + * or draw call. `build` appends into one shared flower mesh. + */ +export namespace Flower { + /** uv center of each bloom color's atlas region (tile units). */ + const BLOOM_UV: Record = { + white: [0.75, 0.25], + red: [0.25, 0.75], + yellow: [0.75, 0.75], + } + /** uv center of the green stem region. */ + const STEM_U = 0.25 + const STEM_V = 0.25 + + export function build(flower: Flower, mesh: Mesh): void { + const rand = rng(flower.seed) + const p = flower.position + const height = flower.size * (0.8 + rand() * 0.4) + const bloomY = p.y + height + const w = flower.size * 0.04 + // Stem: two thin crossed quads so it reads from any angle. + stem(mesh, p.x, p.y, p.z, bloomY, w, 0) + stem(mesh, p.x, p.y, p.z, bloomY, 0, w) + // Bloom: a shallow fan of petals, center raised a touch so it domes. + const [bu, bv] = BLOOM_UV[flower.color] + const rad = flower.size * 0.38 + const center = Mesh.push(mesh, p.x, bloomY + rad * 0.3, p.z, bu, bv) + const ring = center + 1 + const petals = 5 + for (let i = 0; i <= petals; i++) { + const angle = (i / petals) * TAU + rand() * 0.4 + Mesh.push(mesh, p.x + Math.cos(angle) * rad, bloomY, p.z + Math.sin(angle) * rad, bu, bv) + } + for (let i = 0; i < petals; i++) { + mesh.indices.push(center, ring + i, ring + i + 1) + } + } + + /** A thin vertical quad from the ground to `y1`, width along (dx, dz). */ + function stem(mesh: Mesh, x: number, y0: number, z: number, y1: number, dx: number, dz: number): void { + const a = Mesh.push(mesh, x - dx, y0, z - dz, STEM_U, STEM_V) + const b = Mesh.push(mesh, x + dx, y0, z + dz, STEM_U, STEM_V) + const c = Mesh.push(mesh, x + dx, y1, z + dz, STEM_U, STEM_V) + const d = Mesh.push(mesh, x - dx, y1, z - dz, STEM_U, STEM_V) + mesh.indices.push(a, b, c, a, c, d) + } + + /** Deterministic 0..1 generator (mulberry32) seeded per flower. */ + function rng(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6D2B79F5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } +} diff --git a/scripts/gen-assets.ts b/scripts/gen-assets.ts index f6081fd..3fd58ae 100644 --- a/scripts/gen-assets.ts +++ b/scripts/gen-assets.ts @@ -151,6 +151,24 @@ const rock: Shade = (x, y) => { return [g, g + 3, g + 8, 255] } +// Flower color atlas (2x2): green stem (top-left), white / red / yellow blooms. +// Geometry samples each region's flat center, so a flower is one solid color. +const flower: Shade = (x, y) => { + const n = noise(x, y) * 8 + const left = x < 32 + const top = y < 32 + if (top && left) { + return [66 + n, 128 + n, 58 + n, 255] + } + if (top) { + return [238 + n, 240 + n, 245 + n, 255] + } + if (left) { + return [202 + n, 52 + n, 58 + n, 255] + } + return [240 + n, 208 + n, 72 + n, 255] +} + const wall: Shade = (x, y) => { const row = Math.floor(y / 16) const bx = (x + (row % 2) * 16) % 32 @@ -209,6 +227,7 @@ const assets: Array<[string, number, number, Shade]> = [ ["leaf", 64, 64, leaf], ["needle", 64, 64, needle], ["rock", 64, 64, rock], + ["flower", 64, 64, flower], ["wall", 64, 64, wall], ["crate", 64, 64, crate], ["npc", 48, 64, npc],