[READ-ONLY] Mirror of https://github.com/flo-bit/shapecraft.
flo-bit.dev/shapecraft/
7.6 kB
198 lines
1import * as THREE from 'three'
2import { Mesh } from '../core/mesh'
3import { merge } from './merge'
4import { parseColor } from '../core/math'
5import { UberNoise } from '../noise'
6import type { ColorInput } from '../core/types'
7
8export interface SnowOptions {
9 /** Vertical thickness of the snow layer. Default 0.06. */
10 depth?: number
11 /** Max surface slope from horizontal (degrees) that still holds snow. Higher = more coverage. Default 40. */
12 minAngle?: number
13 /** Snow color. Default a cool off-white. */
14 color?: ColorInput
15 /** Horizontal overhang outward from the mesh's vertical axis, giving snow a soft lip. Default depth * 0.6. */
16 spread?: number
17 /** 0..1 — fraction of eligible faces actually covered. < 1 produces patchy, noisy edges. Default 1. */
18 coverage?: number
19 /** Noise scale for coverage patchiness and color variation. Default 2. */
20 noiseScale?: number
21 /** Random brightness variation of the snow color. Default 0.04. */
22 colorVariation?: number
23 /** Seed for the coverage/color noise. Default 0. */
24 seed?: number
25 /** [x, z] axis the snow spreads away from. Defaults to the mesh's bounding-box center. */
26 center?: [number, number]
27 /** If false, return only the snow shell instead of merging it onto the input mesh. Default true. */
28 merge?: boolean
29}
30
31/**
32 * Lay a layer of snow with real thickness on the upward-facing surfaces of a mesh.
33 *
34 * Unlike painting snow as a face color, this builds geometry: a raised "blanket" over
35 * the snow-receiving faces plus skirt walls around its edges, so the snow reads as a
36 * solid layer sitting on top of branches, canopies, rocks, roofs, etc.
37 */
38export function snow(mesh: Mesh, options: SnowOptions = {}): Mesh {
39 const depth = options.depth ?? 0.06
40 const minAngle = options.minAngle ?? 40
41 const spread = options.spread ?? depth * 0.6
42 const coverage = options.coverage ?? 1
43 const noiseScale = options.noiseScale ?? 2
44 const colorVariation = options.colorVariation ?? 0.04
45 const seed = options.seed ?? 0
46 const threshold = Math.cos((minAngle * Math.PI) / 180)
47 const snowColor = parseColor(options.color ?? '#eef0f5')
48
49 let geo = mesh.geometry
50 geo = geo.getIndex() ? geo.toNonIndexed() : geo.clone()
51 const pos = geo.getAttribute('position')
52 const faceCount = pos.count / 3
53
54 // Axis the snow spreads away from (for the overhang lip + skirt orientation).
55 let cx = 0
56 let cz = 0
57 if (options.center) {
58 cx = options.center[0]
59 cz = options.center[1]
60 } else {
61 geo.computeBoundingBox()
62 const bb = geo.boundingBox!
63 cx = (bb.min.x + bb.max.x) / 2
64 cz = (bb.min.z + bb.max.z) / 2
65 }
66
67 const noise = new UberNoise({ seed, scale: noiseScale })
68
69 const q = (n: number) => Math.round(n * 1e5)
70 const edgeKey = (
71 ax: number, ay: number, az: number,
72 bx: number, by: number, bz: number,
73 ): string => {
74 const ka = `${q(ax)},${q(ay)},${q(az)}`
75 const kb = `${q(bx)},${q(by)},${q(bz)}`
76 return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`
77 }
78
79 // Pass 1: classify faces and count edges within the snowy region.
80 interface EdgeRec {
81 ax: number; ay: number; az: number
82 bx: number; by: number; bz: number
83 count: number
84 }
85 const snowyFaces: number[] = []
86 const edges = new Map<string, EdgeRec>()
87
88 for (let f = 0; f < faceCount; f++) {
89 const i = f * 3
90 const ax = pos.getX(i), ay = pos.getY(i), az = pos.getZ(i)
91 const bx = pos.getX(i + 1), by = pos.getY(i + 1), bz = pos.getZ(i + 1)
92 const cxv = pos.getX(i + 2), cyv = pos.getY(i + 2), czv = pos.getZ(i + 2)
93
94 // Face normal (only the up-component matters for the slope test).
95 const e1x = bx - ax, e1y = by - ay, e1z = bz - az
96 const e2x = cxv - ax, e2y = cyv - ay, e2z = czv - az
97 const nx = e1y * e2z - e1z * e2y
98 const ny = e1z * e2x - e1x * e2z
99 const nz = e1x * e2y - e1y * e2x
100 const nl = Math.hypot(nx, ny, nz) || 1
101 if (ny / nl < threshold) continue
102
103 if (coverage < 1) {
104 const mx = (ax + bx + cxv) / 3
105 const my = (ay + by + cyv) / 3
106 const mz = (az + bz + czv) / 3
107 const n01 = noise.get(mx, my, mz) * 0.5 + 0.5
108 if (n01 > coverage) continue
109 }
110
111 snowyFaces.push(f)
112 const tri = [[ax, ay, az], [bx, by, bz], [cxv, cyv, czv]]
113 for (let k = 0; k < 3; k++) {
114 const A = tri[k]
115 const B = tri[(k + 1) % 3]
116 const key = edgeKey(A[0], A[1], A[2], B[0], B[1], B[2])
117 const rec = edges.get(key)
118 if (rec) rec.count++
119 else edges.set(key, { ax: A[0], ay: A[1], az: A[2], bx: B[0], by: B[1], bz: B[2], count: 1 })
120 }
121 }
122
123 if (snowyFaces.length === 0) {
124 return options.merge === false ? new Mesh(new THREE.BufferGeometry()) : mesh.clone()
125 }
126
127 const sp: number[] = []
128 const sc: number[] = []
129
130 const raise = (x: number, y: number, z: number): [number, number, number] => {
131 let dx = x - cx
132 let dz = z - cz
133 const dl = Math.hypot(dx, dz)
134 if (dl > 1e-6) { dx /= dl; dz /= dl } else { dx = 0; dz = 0 }
135 return [x + dx * spread, y + depth, z + dz * spread]
136 }
137 const pushColor = (mx: number, my: number, mz: number) => {
138 const v = noise.get(mx + 10, my + 10, mz + 10) * colorVariation
139 sc.push(
140 Math.min(1, Math.max(0, snowColor.r + v)),
141 Math.min(1, Math.max(0, snowColor.g + v)),
142 Math.min(1, Math.max(0, snowColor.b + v)),
143 )
144 }
145
146 // The raised snow blanket (same winding as the source faces → normals point up).
147 for (const f of snowyFaces) {
148 const i = f * 3
149 const a = raise(pos.getX(i), pos.getY(i), pos.getZ(i))
150 const b = raise(pos.getX(i + 1), pos.getY(i + 1), pos.getZ(i + 1))
151 const c = raise(pos.getX(i + 2), pos.getY(i + 2), pos.getZ(i + 2))
152 sp.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2])
153 const mx = (a[0] + b[0] + c[0]) / 3
154 const my = (a[1] + b[1] + c[1]) / 3
155 const mz = (a[2] + b[2] + c[2]) / 3
156 pushColor(mx, my, mz); pushColor(mx, my, mz); pushColor(mx, my, mz)
157 }
158
159 // Skirt walls along the region boundary (edges used by exactly one snowy face).
160 for (const rec of edges.values()) {
161 if (rec.count !== 1) continue
162 const A: [number, number, number] = [rec.ax, rec.ay, rec.az]
163 const B: [number, number, number] = [rec.bx, rec.by, rec.bz]
164 const Ar = raise(A[0], A[1], A[2])
165 const Br = raise(B[0], B[1], B[2])
166
167 // Orient the wall outward (its horizontal normal should point away from center).
168 const midx = (A[0] + B[0]) / 2
169 const midz = (A[2] + B[2]) / 2
170 let ox = midx - cx, oz = midz - cz
171 const ol = Math.hypot(ox, oz) || 1
172 ox /= ol; oz /= ol
173 const ux = B[0] - A[0], uy = B[1] - A[1], uz = B[2] - A[2]
174 const vx = Br[0] - A[0], vy = Br[1] - A[1], vz = Br[2] - A[2]
175 const tnx = uy * vz - uz * vy
176 const tnz = ux * vy - uy * vx
177
178 if (tnx * ox + tnz * oz >= 0) {
179 sp.push(A[0], A[1], A[2], B[0], B[1], B[2], Br[0], Br[1], Br[2])
180 sp.push(A[0], A[1], A[2], Br[0], Br[1], Br[2], Ar[0], Ar[1], Ar[2])
181 } else {
182 sp.push(A[0], A[1], A[2], Br[0], Br[1], Br[2], B[0], B[1], B[2])
183 sp.push(A[0], A[1], A[2], Ar[0], Ar[1], Ar[2], Br[0], Br[1], Br[2])
184 }
185 const mx = (A[0] + B[0]) / 2
186 const my = (A[1] + B[1]) / 2 + depth / 2
187 const mz = (A[2] + B[2]) / 2
188 for (let k = 0; k < 6; k++) pushColor(mx, my, mz)
189 }
190
191 const snowGeo = new THREE.BufferGeometry()
192 snowGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(sp), 3))
193 snowGeo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(sc), 3))
194 snowGeo.computeVertexNormals()
195 const snowMesh = new Mesh(snowGeo)
196
197 return options.merge === false ? snowMesh : merge(mesh, snowMesh)
198}