feat: foilage
This commit is contained in:
parent
67cd54fe33
commit
6fb46573da
9 changed files with 270 additions and 19 deletions
31
AGENTS.md
31
AGENTS.md
|
|
@ -69,7 +69,9 @@ rules live in `.agents/rules/*.md`.
|
||||||
(procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob;
|
(procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob;
|
||||||
`Tree.build` appends into shared trunk + foliage meshes), `Boulder`
|
`Tree.build` appends into shared trunk + foliage meshes), `Boulder`
|
||||||
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
|
(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.
|
- `app/` — browser glue.
|
||||||
- `main.ts` — game loop, input, preset switching, canvas blit, FPS meter.
|
- `main.ts` — game loop, input, preset switching, canvas blit, FPS meter.
|
||||||
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes
|
- `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
|
- `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
|
walls via `slab`, north side open) always drawn, in the center of a big grassy
|
||||||
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
|
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
|
||||||
`placeBoulders` → instance lists + colliders; `TREE_/BOULDER_COUNT`/`_SEED`/
|
`placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
|
||||||
`_REACH`), then `buildChunks` bakes terrain + props into a `CHUNK_GRID` x
|
`TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`), then `buildChunks` bakes
|
||||||
`CHUNK_GRID` grid of `Chunk`s (each = per-texture meshes grass/bark/leaf/needle/
|
terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each =
|
||||||
rock + a tight AABB) that `main` frustum-culls. `Aabb` colliders (walls, crate,
|
per-texture meshes grass/bark/leaf/needle/rock/flowers + a tight AABB) that
|
||||||
grown trunks, big boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`,
|
`main` frustum-culls; bushes fold into the leaf mesh, flowers get their own
|
||||||
sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it
|
(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
|
stays clean over the terrain skirt that laps under the room edges. Room surfaces
|
||||||
are single flat quads -- no subdivision needed since texturing is
|
are single flat quads -- no subdivision needed since texturing is
|
||||||
perspective-correct.
|
perspective-correct.
|
||||||
|
|
@ -94,9 +97,10 @@ rules live in `.agents/rules/*.md`.
|
||||||
`#fps` meter div (styled inline).
|
`#fps` meter div (styled inline).
|
||||||
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
|
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
|
||||||
writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`.
|
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
|
- `assets/` — generated `floor/grass/bark/leaf/needle/rock/flower/wall/crate/npc`
|
||||||
(`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = tree
|
PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` =
|
||||||
trunk/oak/spruce, `rock` = boulders). Swap for real art anytime;
|
tree trunk/oak/spruce, `rock` = boulders, `flower` = 2x2 bloom-color atlas).
|
||||||
|
Swap for real art anytime;
|
||||||
filenames are the contract.
|
filenames are the contract.
|
||||||
- `server/` — Bun server stub. `shared/` — isomorphic slot.
|
- `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) →
|
`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) →
|
||||||
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`,
|
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`,
|
||||||
then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock,
|
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;
|
`Framebuffer.quantize` → `present` (integer-scale, letterboxed blit;
|
||||||
`imageSmoothingEnabled` follows `upscaleFilter`).
|
`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
|
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.
|
never cracks) into one shared rock mesh, sunk partway into the ground.
|
||||||
`scatterBoulders` sizes them small→big (biased small) and drops colliders on the
|
`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
|
## Controls
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Texture } from "../engine/render/Texture"
|
||||||
import barkUrl from "../assets/bark.png"
|
import barkUrl from "../assets/bark.png"
|
||||||
import crateUrl from "../assets/crate.png"
|
import crateUrl from "../assets/crate.png"
|
||||||
import floorUrl from "../assets/floor.png"
|
import floorUrl from "../assets/floor.png"
|
||||||
|
import flowerUrl from "../assets/flower.png"
|
||||||
import grassUrl from "../assets/grass.png"
|
import grassUrl from "../assets/grass.png"
|
||||||
import leafUrl from "../assets/leaf.png"
|
import leafUrl from "../assets/leaf.png"
|
||||||
import needleUrl from "../assets/needle.png"
|
import needleUrl from "../assets/needle.png"
|
||||||
|
|
@ -16,6 +17,7 @@ export type Textures = {
|
||||||
leaf: Texture
|
leaf: Texture
|
||||||
needle: Texture
|
needle: Texture
|
||||||
rock: Texture
|
rock: Texture
|
||||||
|
flower: Texture
|
||||||
wall: Texture
|
wall: Texture
|
||||||
crate: Texture
|
crate: Texture
|
||||||
npc: Texture
|
npc: Texture
|
||||||
|
|
@ -23,18 +25,19 @@ export type Textures = {
|
||||||
|
|
||||||
/** Load every game texture up front. Call once before starting the loop. */
|
/** Load every game texture up front. Call once before starting the loop. */
|
||||||
export async function loadTextures(): Promise<Textures> {
|
export async function loadTextures(): Promise<Textures> {
|
||||||
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(floorUrl),
|
||||||
loadTexture(grassUrl),
|
loadTexture(grassUrl),
|
||||||
loadTexture(barkUrl),
|
loadTexture(barkUrl),
|
||||||
loadTexture(leafUrl),
|
loadTexture(leafUrl),
|
||||||
loadTexture(needleUrl),
|
loadTexture(needleUrl),
|
||||||
loadTexture(rockUrl),
|
loadTexture(rockUrl),
|
||||||
|
loadTexture(flowerUrl),
|
||||||
loadTexture(wallUrl),
|
loadTexture(wallUrl),
|
||||||
loadTexture(crateUrl),
|
loadTexture(crateUrl),
|
||||||
loadTexture(npcUrl),
|
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<Texture> {
|
function loadTexture(url: string): Promise<Texture> {
|
||||||
|
|
|
||||||
75
app/level.ts
75
app/level.ts
|
|
@ -2,6 +2,8 @@ import { Color } from "../engine/render/Color"
|
||||||
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
|
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
|
||||||
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
|
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
|
||||||
import { Boulder } from "../engine/scene/Boulder"
|
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 { Terrain } from "../engine/scene/Terrain"
|
||||||
import { Tree } from "../engine/scene/Tree"
|
import { Tree } from "../engine/scene/Tree"
|
||||||
|
|
||||||
|
|
@ -35,6 +37,8 @@ export type Chunk = {
|
||||||
leaf: Mesh
|
leaf: Mesh
|
||||||
needle: Mesh
|
needle: Mesh
|
||||||
rock: 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
|
/** 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_SEED = 0xB0142
|
||||||
const BOULDER_REACH = 0.7
|
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
|
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
|
||||||
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
|
* `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
|
* (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.
|
// frustum-cullable spatial chunks.
|
||||||
const trees = placeTrees(colliders)
|
const trees = placeTrees(colliders)
|
||||||
const boulders = placeBoulders(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 }
|
return { floor, walls, crate, chunks, colliders, npcPosition, terrain: TERRAIN, sky }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial
|
/** 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
|
* 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. */
|
* grown to the real geometry so overhanging canopies never get culled early.
|
||||||
function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] {
|
* 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 cell = (TERRAIN.outer * 2) / CHUNK_GRID
|
||||||
const chunks: Chunk[] = []
|
const chunks: Chunk[] = []
|
||||||
for (let ci = 0; ci < CHUNK_GRID; ci++) {
|
for (let ci = 0; ci < CHUNK_GRID; ci++) {
|
||||||
|
|
@ -197,6 +214,7 @@ function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] {
|
||||||
const leaf = mesh()
|
const leaf = mesh()
|
||||||
const needle = mesh()
|
const needle = mesh()
|
||||||
const rock = mesh()
|
const rock = mesh()
|
||||||
|
const flowerMesh = mesh()
|
||||||
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
|
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
|
||||||
for (const tree of trees) {
|
for (const tree of trees) {
|
||||||
if (inCell(tree.position, x0, z0, x1, z1)) {
|
if (inCell(tree.position, x0, z0, x1, z1)) {
|
||||||
|
|
@ -208,11 +226,21 @@ function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] {
|
||||||
Boulder.build(boulder, rock)
|
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) {
|
if (b === null) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
chunks.push({ ...b, grass, bark, leaf, needle, rock })
|
chunks.push({ ...b, grass, bark, leaf, needle, rock, flowers: flowerMesh })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return chunks
|
return chunks
|
||||||
|
|
@ -302,6 +330,43 @@ function placeBoulders(colliders: Aabb[]): Boulder[] {
|
||||||
return boulders
|
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. */
|
/** Deterministic 0..1 generator (mulberry32) for tree placement. */
|
||||||
function mulberry(seed: number): () => number {
|
function mulberry(seed: number): () => number {
|
||||||
let a = seed >>> 0
|
let a = seed >>> 0
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ async function main(): Promise<void> {
|
||||||
Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true)
|
Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true)
|
||||||
Rasterizer.draw(fb, c.leaf, textures.leaf, 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.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)
|
Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config)
|
||||||
Framebuffer.quantize(fb, config)
|
Framebuffer.quantize(fb, config)
|
||||||
|
|
|
||||||
BIN
assets/flower.png
Normal file
BIN
assets/flower.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
|
|
@ -94,6 +94,7 @@ export namespace RenderConfig {
|
||||||
colorDepth: 8,
|
colorDepth: 8,
|
||||||
dither: 0,
|
dither: 0,
|
||||||
vertexSnap: 0,
|
vertexSnap: 0,
|
||||||
|
perspectiveCorrect: 1,
|
||||||
textureFilter: "linear",
|
textureFilter: "linear",
|
||||||
lighting: "flat",
|
lighting: "flat",
|
||||||
fog: null,
|
fog: null,
|
||||||
|
|
|
||||||
76
engine/scene/Bush.ts
Normal file
76
engine/scene/Bush.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
engine/scene/Flower.ts
Normal file
79
engine/scene/Flower.ts
Normal file
|
|
@ -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<FlowerColor, [number, number]> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -151,6 +151,24 @@ const rock: Shade = (x, y) => {
|
||||||
return [g, g + 3, g + 8, 255]
|
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 wall: Shade = (x, y) => {
|
||||||
const row = Math.floor(y / 16)
|
const row = Math.floor(y / 16)
|
||||||
const bx = (x + (row % 2) * 16) % 32
|
const bx = (x + (row % 2) * 16) % 32
|
||||||
|
|
@ -209,6 +227,7 @@ const assets: Array<[string, number, number, Shade]> = [
|
||||||
["leaf", 64, 64, leaf],
|
["leaf", 64, 64, leaf],
|
||||||
["needle", 64, 64, needle],
|
["needle", 64, 64, needle],
|
||||||
["rock", 64, 64, rock],
|
["rock", 64, 64, rock],
|
||||||
|
["flower", 64, 64, flower],
|
||||||
["wall", 64, 64, wall],
|
["wall", 64, 64, wall],
|
||||||
["crate", 64, 64, crate],
|
["crate", 64, 64, crate],
|
||||||
["npc", 48, 64, npc],
|
["npc", 48, 64, npc],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue