From d92d59c7e24dbb4d615994fcc23e511a1b6656bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCrrhauer?= Date: Mon, 31 Aug 2026 23:33:58 +0200 Subject: [PATCH 1/2] fix: render grass as tapered ribbons --- src/grass-geometry.test.ts | 19 +++++++++++++++++++ src/grass-geometry.ts | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 src/grass-geometry.test.ts diff --git a/src/grass-geometry.test.ts b/src/grass-geometry.test.ts new file mode 100644 index 0000000..ecc79e4 --- /dev/null +++ b/src/grass-geometry.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test' +import { getGrassVariant } from './grass-geometry' + +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({ bladeColor: '#5a8f3c', preset, seed } as never) + expect(variant.subMeshes).toHaveLength(1) + + const geometry = variant.subMeshes[0]!.geometry + expect(geometry.attributes.position!.count).toBe(bladeCount * 4) + expect(geometry.index?.count).toBe(bladeCount * 6) + }) +}) diff --git a/src/grass-geometry.ts b/src/grass-geometry.ts index 6bca0ae..40ee26a 100644 --- a/src/grass-geometry.ts +++ b/src/grass-geometry.ts @@ -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' @@ -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) From 9ea57d9d445907121f201bf27c0903fb63fe9c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCrrhauer?= Date: Mon, 31 Aug 2026 23:50:23 +0200 Subject: [PATCH 2/2] test: verify tapered grass ribbon contract --- src/grass-geometry.test.ts | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/grass-geometry.test.ts b/src/grass-geometry.test.ts index ecc79e4..eea02f3 100644 --- a/src/grass-geometry.test.ts +++ b/src/grass-geometry.test.ts @@ -1,5 +1,7 @@ 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 }, @@ -9,11 +11,41 @@ const CASES = [ describe('grass geometry', () => { test.each(CASES)('$preset uses one flat two-triangle ribbon per blade', ({ bladeCount, preset, seed }) => { - const variant = getGrassVariant({ bladeColor: '#5a8f3c', preset, seed } as never) + const variant = getGrassVariant(GrassNode.parse({ bladeColor: '#5a8f3c', preset, seed })) expect(variant.subMeshes).toHaveLength(1) - const geometry = variant.subMeshes[0]!.geometry - expect(geometry.attributes.position!.count).toBe(bladeCount * 4) + 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) + } }) })