Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/grass-geometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, test } from 'bun:test'
import { DoubleSide, Vector3 } from 'three'
import { getGrassVariant } from './grass-geometry'
import { GrassNode } from './grass-schema'

const CASES = [
{ bladeCount: 10, preset: 'meadow' as const, seed: 1 },
{ bladeCount: 8, preset: 'fescue' as const, seed: 7 },
{ bladeCount: 6, preset: 'reed' as const, seed: 13 },
]

describe('grass geometry', () => {
test.each(CASES)('$preset uses one flat two-triangle ribbon per blade', ({ bladeCount, preset, seed }) => {
const variant = getGrassVariant(GrassNode.parse({ bladeColor: '#5a8f3c', preset, seed }))
expect(variant.subMeshes).toHaveLength(1)

const { geometry, material } = variant.subMeshes[0]!
const position = geometry.attributes.position!
if (Array.isArray(material)) throw new Error(`${preset} grass must use one shared material`)
expect(material.side).toBe(DoubleSide)
expect(position.count).toBe(bladeCount * 4)
expect(geometry.attributes.normal?.count).toBe(position.count)
expect(geometry.attributes.uv?.count).toBe(position.count)
expect(geometry.index?.count).toBe(bladeCount * 6)

for (let bladeIndex = 0; bladeIndex < bladeCount; bladeIndex++) {
const offset = bladeIndex * 4
const points = Array.from({ length: 4 }, (_, index) =>
new Vector3().fromBufferAttribute(position, offset + index),
)
const [baseLeft, baseRight, tipRight, tipLeft] = points as [
Vector3,
Vector3,
Vector3,
Vector3,
]
const planeNormal = new Vector3()
.subVectors(baseRight, baseLeft)
.cross(new Vector3().subVectors(tipRight, baseLeft))
.normalize()
const fourthPointDistance = Math.abs(
new Vector3().subVectors(tipLeft, baseLeft).dot(planeNormal),
)

expect(baseLeft.distanceTo(baseRight)).toBeCloseTo(0.012, 5)
expect(tipLeft.distanceTo(tipRight)).toBeCloseTo(0.0012, 5)
expect(baseLeft.distanceTo(tipLeft)).toBeGreaterThan(0.2)
expect(fourthPointDistance).toBeLessThan(0.000001)
}
})
})
34 changes: 30 additions & 4 deletions src/grass-geometry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type BufferGeometry, ConeGeometry, DoubleSide, Group, Mesh } from 'three'
import { BufferGeometry, DoubleSide, Float32BufferAttribute, Group, Mesh } from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { GRASS_PRESETS } from './grass-presets'
import type { GrassNode, GrassPreset } from './grass-schema'
Expand Down Expand Up @@ -40,9 +40,35 @@ function buildGrass(preset: GrassPreset, seed: number, bladeColor: string): Grou
const blades: BufferGeometry[] = []
for (let i = 0; i < spec.blades; i++) {
const bh = h * (0.6 + rng() * 0.6)
const blade = new ConeGeometry(0.02, bh, 3)
blade.scale(1, 1, 0.3) // flatten the cone into a blade
blade.translate(0, bh / 2, 0)
const halfBaseWidth = 0.006
const halfTipWidth = 0.0006
const blade = new BufferGeometry()
blade.setAttribute(
'position',
new Float32BufferAttribute(
[
-halfBaseWidth,
0,
0,
halfBaseWidth,
0,
0,
halfTipWidth,
bh,
0,
-halfTipWidth,
bh,
0,
],
3,
),
)
blade.setAttribute(
'uv',
new Float32BufferAttribute([0, 0, 1, 0, 0.55, 1, 0.45, 1], 2),
)
blade.setIndex([0, 1, 2, 0, 2, 3])
blade.computeVertexNormals()
blade.rotateZ((rng() - 0.5) * 0.7) // lean
const angle = rng() * Math.PI * 2
blade.rotateY(angle)
Expand Down