trid3d 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -9
- package/package.json +2 -1
- package/trid/export/capture.js +150 -0
- package/trid/export/mtl.js +119 -0
- package/trid/export/obj.js +227 -0
- package/trid/index.d.ts +630 -16
- package/trid/index.js +29 -6
- package/trid/loaders/mtl.js +63 -0
- package/trid/loaders/obj.js +72 -4
- package/trid/loaders/texture.js +47 -6
- package/trid/math/matrix.js +200 -0
- package/trid/math/noise.js +162 -0
- package/trid/math/rotation.js +152 -0
- package/trid/render/canvasRenderer.js +132 -82
- package/trid/render/draw.js +21 -6
- package/trid/render/meshUtils.js +257 -0
- package/trid/render/proceduralTextures.js +211 -0
- package/trid/render/shapes.js +353 -23
- package/trid/render/uvMapping.js +225 -0
- package/trid/render/webglRenderer.js +240 -75
- package/trid/scene.js +43 -9
- package/trid/sceneGraph.js +131 -0
- package/trid/tools/dragObject.js +33 -9
- package/trid/tools/helpers.js +109 -10
- package/trid/tools/hoverHighlight.js +6 -3
- package/trid/tools/index.js +3 -1
- package/trid/tools/orbitCamera.js +27 -0
- package/trid/tools/transformGizmo.js +316 -0
- package/trid/tools/viewGizmo.js +178 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { localToWorld, rotateXYZ } from "../math/transform.js";
|
|
2
|
+
import { transformPoint, normalMatrix, transformNormal, determinantMat4 } from "../math/matrix.js";
|
|
3
|
+
import { forEachPlaced, worldMatrix } from "../sceneGraph.js";
|
|
4
|
+
|
|
5
|
+
/** Deep copy of one mesh field: arrays and plain objects are copied, the rest kept. */
|
|
6
|
+
const copy = (x) => (Array.isArray(x) ? x.map(copy) : x && typeof x === "object" ? { ...x } : x);
|
|
7
|
+
|
|
8
|
+
/** A transform with every part filled in. */
|
|
9
|
+
const fullTransform = ({ pos, rot, scale } = {}) => ({
|
|
10
|
+
pos: { x: 0, y: 0, z: 0, ...pos },
|
|
11
|
+
rot: { x: 0, y: 0, z: 0, ...rot },
|
|
12
|
+
scale: { x: 1, y: 1, z: 1, ...scale },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** A copy of a mesh with a world matrix baked in (normals turned, faces flipped for mirrors). */
|
|
16
|
+
function bake(mesh, world) {
|
|
17
|
+
const out = cloneMesh(mesh);
|
|
18
|
+
for (const v of out.vertices) Object.assign(v, transformPoint(world, v));
|
|
19
|
+
if (out.normals) {
|
|
20
|
+
const nm = normalMatrix(world);
|
|
21
|
+
out.normals = out.normals.map((n) => transformNormal(nm, n));
|
|
22
|
+
}
|
|
23
|
+
if (determinantMat4(world) < 0 && out.faces) {
|
|
24
|
+
const flip = (list) => list?.map((f) => (f ? [...f].reverse() : f));
|
|
25
|
+
out.faces = flip(out.faces);
|
|
26
|
+
if (out.faceUVs) out.faceUVs = flip(out.faceUVs);
|
|
27
|
+
if (out.faceNormals) out.faceNormals = flip(out.faceNormals);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deep copy of a mesh: vertices, edges, faces, texture coordinates, normals
|
|
34
|
+
* and materials. Change the copy without touching the original (or the other
|
|
35
|
+
* objects that share it).
|
|
36
|
+
*
|
|
37
|
+
* @template M
|
|
38
|
+
* @param {M} mesh
|
|
39
|
+
* @returns {M} A new mesh
|
|
40
|
+
*/
|
|
41
|
+
export function cloneMesh(mesh) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const k of Object.keys(mesh)) if (k !== "dirty") out[k] = copy(mesh[k]);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bakes a transform into a mesh's vertices, in place: scale, then rotate
|
|
49
|
+
* (X, then Y, then Z), then move, the same order objects use. Its own normals
|
|
50
|
+
* turn with it, and a mirroring scale (an odd number of negative factors)
|
|
51
|
+
* reverses the faces so they still face outward.
|
|
52
|
+
*
|
|
53
|
+
* Pass a scene object to bake its `pos`, `rot` and `scale` (then reset them
|
|
54
|
+
* on the object, or it's transformed twice).
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* // A handle standing on the side of a mug
|
|
58
|
+
* transformMesh(handle, { pos: { x: 0.3, y: 0, z: 0 }, rot: { x: 0, y: 0, z: Math.PI / 2 } });
|
|
59
|
+
*
|
|
60
|
+
* @template M
|
|
61
|
+
* @param {M} mesh - Mesh to change
|
|
62
|
+
* @param {{ pos?:{x:number,y:number,z:number}, rot?:{x:number,y:number,z:number}, scale?:{x:number,y:number,z:number} }} transform
|
|
63
|
+
* Parts left out don't change anything
|
|
64
|
+
* @returns {M} The same mesh
|
|
65
|
+
*/
|
|
66
|
+
export function transformMesh(mesh, transform) {
|
|
67
|
+
const t = fullTransform(transform);
|
|
68
|
+
for (const v of mesh.vertices) Object.assign(v, localToWorld(v, t));
|
|
69
|
+
|
|
70
|
+
// Normals: divide by the scale (keeps them perpendicular to stretched faces), turn, then normalize
|
|
71
|
+
if (mesh.normals) {
|
|
72
|
+
const s = t.scale;
|
|
73
|
+
mesh.normals = mesh.normals.map((n) => {
|
|
74
|
+
const r = rotateXYZ({ x: n.x / (s.x || 1), y: n.y / (s.y || 1), z: n.z / (s.z || 1) }, t.rot);
|
|
75
|
+
const len = Math.hypot(r.x, r.y, r.z) || 1;
|
|
76
|
+
return { x: r.x / len, y: r.y / len, z: r.z / len };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A mirror turns clockwise faces counter-clockwise: reverse them (and their corner lists)
|
|
81
|
+
if (t.scale.x * t.scale.y * t.scale.z < 0 && mesh.faces) {
|
|
82
|
+
const flip = (list) => list?.map((f) => (f ? [...f].reverse() : f));
|
|
83
|
+
mesh.faces = flip(mesh.faces);
|
|
84
|
+
if (mesh.faceUVs) mesh.faceUVs = flip(mesh.faceUVs);
|
|
85
|
+
if (mesh.faceNormals) mesh.faceNormals = flip(mesh.faceNormals);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
mesh.dirty = true;
|
|
89
|
+
return mesh;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Moves every vertex to `fn(vertex, index)`, in place: noise on a rock,
|
|
94
|
+
* waves on a plane, a bend or a twist.
|
|
95
|
+
*
|
|
96
|
+
* The mesh's own normals (from an OBJ file) no longer fit the new shape, so
|
|
97
|
+
* they're removed and the renderers compute new ones.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* // A lumpy rock
|
|
101
|
+
* displaceMesh(makeShape("sphere"), (v) => {
|
|
102
|
+
* const k = 1 + 0.3 * (fbm3(v.x * 3, v.y * 3, v.z * 3) - 0.5);
|
|
103
|
+
* return { x: v.x * k, y: v.y * k, z: v.z * k };
|
|
104
|
+
* });
|
|
105
|
+
*
|
|
106
|
+
* @template M
|
|
107
|
+
* @param {M} mesh - Mesh to change
|
|
108
|
+
* @param {(v:{x:number,y:number,z:number}, index:number) => {x:number,y:number,z:number}} fn - New position of a vertex
|
|
109
|
+
* @returns {M} The same mesh
|
|
110
|
+
*/
|
|
111
|
+
export function displaceMesh(mesh, fn) {
|
|
112
|
+
mesh.vertices.forEach((v, i) => {
|
|
113
|
+
const p = fn({ x: v.x, y: v.y, z: v.z }, i);
|
|
114
|
+
v.x = p.x;
|
|
115
|
+
v.y = p.y;
|
|
116
|
+
v.z = p.z;
|
|
117
|
+
});
|
|
118
|
+
delete mesh.normals;
|
|
119
|
+
delete mesh.faceNormals;
|
|
120
|
+
mesh.dirty = true;
|
|
121
|
+
return mesh;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Joins meshes into one: one object to draw instead of many, or one mesh to
|
|
126
|
+
* export. Faces, edges, texture coordinates, normals and materials are kept,
|
|
127
|
+
* with their indices shifted.
|
|
128
|
+
*
|
|
129
|
+
* An object (`{ mesh, pos, rot, scale }`, added to a scene or not) in the
|
|
130
|
+
* list is merged where it stands, with its transform baked in (its mesh
|
|
131
|
+
* isn't changed). A group is merged with all its children, placed by the
|
|
132
|
+
* group's own transform (not its parents').
|
|
133
|
+
*
|
|
134
|
+
* With `materialPerMesh`, the faces of the i-th mesh use material i, so an
|
|
135
|
+
* object with `material: [blade, guard, grip]` paints each part its own way.
|
|
136
|
+
* Otherwise each mesh's `faceMaterials` are kept; material names (from OBJ
|
|
137
|
+
* files) are joined into one list when every mesh with materials has them.
|
|
138
|
+
*
|
|
139
|
+
* The meshes are copied, never changed.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* const sword = mergeMeshes([blade, guard, grip], { materialPerMesh: true });
|
|
143
|
+
* scene.add({ mesh: sword, material: [{ fill: "#ccd" }, { fill: "#c90" }, { fill: "#642" }] });
|
|
144
|
+
*
|
|
145
|
+
* @param {Object[]} items - Meshes, or scene objects with a `mesh`
|
|
146
|
+
* @param {Object} [opts]
|
|
147
|
+
* @param {boolean} [opts.materialPerMesh=false] - Faces of the i-th mesh use material i
|
|
148
|
+
* @returns {import("../index.d.ts").Mesh} A new mesh
|
|
149
|
+
*/
|
|
150
|
+
export function mergeMeshes(items, { materialPerMesh = false } = {}) {
|
|
151
|
+
// Meshes as they are; objects and groups baked where they stand, one entry per item
|
|
152
|
+
const meshes = items.map((it) => {
|
|
153
|
+
if (it.vertices) return it;
|
|
154
|
+
const parts = [];
|
|
155
|
+
forEachPlaced([it], () => null, (obj, world) => {
|
|
156
|
+
if (obj.mesh) parts.push(bake(obj.mesh, world));
|
|
157
|
+
});
|
|
158
|
+
return parts.length === 1 ? parts[0] : mergeMeshes(parts);
|
|
159
|
+
});
|
|
160
|
+
const some = (k) => meshes.some((m) => m[k]);
|
|
161
|
+
|
|
162
|
+
const out = { vertices: [], faces: [] };
|
|
163
|
+
const hasEdges = some("edges");
|
|
164
|
+
const hasUVs = some("faceUVs") && some("uvs");
|
|
165
|
+
const hasNormals = some("faceNormals") && some("normals");
|
|
166
|
+
const hasMats = materialPerMesh || some("faceMaterials");
|
|
167
|
+
const withMats = meshes.filter((m) => m.faceMaterials);
|
|
168
|
+
const names = !materialPerMesh && withMats.length > 0 && withMats.every((m) => m.materialNames) ? [] : null;
|
|
169
|
+
if (hasEdges) out.edges = [];
|
|
170
|
+
if (hasUVs) Object.assign(out, { uvs: [], faceUVs: [] });
|
|
171
|
+
if (hasNormals) Object.assign(out, { normals: [], faceNormals: [] });
|
|
172
|
+
if (hasMats) out.faceMaterials = [];
|
|
173
|
+
|
|
174
|
+
meshes.forEach((m, mi) => {
|
|
175
|
+
const vo = out.vertices.length, uo = out.uvs?.length ?? 0, no = out.normals?.length ?? 0;
|
|
176
|
+
const shift = (list, o) => list.map((i) => i + o);
|
|
177
|
+
out.vertices.push(...m.vertices.map((v) => ({ x: v.x, y: v.y, z: v.z })));
|
|
178
|
+
// A mesh without edges keeps showing its face outlines next to meshes that have some
|
|
179
|
+
if (hasEdges) out.edges.push(...(m.edges ?? m.faces ?? []).map((e) => shift(e, vo)));
|
|
180
|
+
if (hasUVs) out.uvs.push(...(m.uvs ?? []).map((t) => ({ u: t.u, v: t.v })));
|
|
181
|
+
if (hasNormals) out.normals.push(...(m.normals ?? []).map((n) => ({ x: n.x, y: n.y, z: n.z })));
|
|
182
|
+
|
|
183
|
+
// Material names: this mesh's index k → the index of its name in the joined list
|
|
184
|
+
const remap = names && m.materialNames
|
|
185
|
+
? m.materialNames.map((n) => (names.includes(n) ? names.indexOf(n) : names.push(n) - 1))
|
|
186
|
+
: null;
|
|
187
|
+
|
|
188
|
+
(m.faces ?? []).forEach((f, fi) => {
|
|
189
|
+
out.faces.push(shift(f, vo));
|
|
190
|
+
if (hasUVs) {
|
|
191
|
+
const fu = m.uvs && m.faceUVs?.[fi];
|
|
192
|
+
out.faceUVs.push(fu ? shift(fu, uo) : null);
|
|
193
|
+
}
|
|
194
|
+
if (hasNormals) {
|
|
195
|
+
const fn = m.normals && m.faceNormals?.[fi];
|
|
196
|
+
out.faceNormals.push(fn ? shift(fn, no) : null);
|
|
197
|
+
}
|
|
198
|
+
if (hasMats) {
|
|
199
|
+
const k = m.faceMaterials?.[fi] ?? 0;
|
|
200
|
+
out.faceMaterials.push(materialPerMesh ? mi : remap ? (remap[k] ?? 0) : k);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (names) out.materialNames = names;
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The box around one or more meshes: `min` and `max` corners, plus its
|
|
211
|
+
* `center` and `size`. Scene objects (and a whole Scene) are measured where
|
|
212
|
+
* they stand, with their transform.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* const { center, size } = meshBounds(scene);
|
|
216
|
+
* orbit.target = center;
|
|
217
|
+
* orbit.distance = Math.max(size.x, size.y, size.z) * 2;
|
|
218
|
+
*
|
|
219
|
+
* @param {Object|Object[]} input - A mesh, a scene object, a list of them, or a Scene
|
|
220
|
+
* @returns {{ min:{x:number,y:number,z:number}, max:{x:number,y:number,z:number},
|
|
221
|
+
* center:{x:number,y:number,z:number}, size:{x:number,y:number,z:number} } | null}
|
|
222
|
+
* null when there are no vertices
|
|
223
|
+
*/
|
|
224
|
+
export function meshBounds(input) {
|
|
225
|
+
const items = Array.isArray(input) ? input : Array.isArray(input?.objects) ? input.objects : [input];
|
|
226
|
+
const min = { x: Infinity, y: Infinity, z: Infinity };
|
|
227
|
+
const max = { x: -Infinity, y: -Infinity, z: -Infinity };
|
|
228
|
+
let any = false;
|
|
229
|
+
// Plain meshes as they are; objects (and their children) where they're drawn
|
|
230
|
+
const placed = [];
|
|
231
|
+
const objects = [];
|
|
232
|
+
for (const it of items) {
|
|
233
|
+
if (it?.vertices) placed.push({ mesh: it, world: null });
|
|
234
|
+
else if (it) objects.push(it);
|
|
235
|
+
}
|
|
236
|
+
forEachPlaced(objects, (root) => (root.parent ? worldMatrix(root.parent) : null), (obj, world) => {
|
|
237
|
+
if (obj.mesh?.vertices) placed.push({ mesh: obj.mesh, world });
|
|
238
|
+
});
|
|
239
|
+
for (const { mesh, world } of placed) {
|
|
240
|
+
for (const v of mesh.vertices) {
|
|
241
|
+
const p = world ? transformPoint(world, v) : v;
|
|
242
|
+
any = true;
|
|
243
|
+
if (p.x < min.x) min.x = p.x;
|
|
244
|
+
if (p.y < min.y) min.y = p.y;
|
|
245
|
+
if (p.z < min.z) min.z = p.z;
|
|
246
|
+
if (p.x > max.x) max.x = p.x;
|
|
247
|
+
if (p.y > max.y) max.y = p.y;
|
|
248
|
+
if (p.z > max.z) max.z = p.z;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!any) return null;
|
|
252
|
+
return {
|
|
253
|
+
min, max,
|
|
254
|
+
center: { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 },
|
|
255
|
+
size: { x: max.x - min.x, y: max.y - min.y, z: max.z - min.z },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { fbm2, noise2 } from "../math/noise.js";
|
|
2
|
+
import { updateTexture } from "../loaders/texture.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Procedural textures: patterns painted in code, from a style, two colors
|
|
6
|
+
* and a seed. Every style tiles with no seam (the noise repeats and every
|
|
7
|
+
* count is a whole number), so they wrap around spheres, lathes and
|
|
8
|
+
* repeated textures cleanly. Like a canvas painted once, they're uploaded
|
|
9
|
+
* once.
|
|
10
|
+
*
|
|
11
|
+
* scene.add({ mesh: makeShape("cube"), material: { texture: makeTexture("wood") } });
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The built-in styles of `makeTexture`. */
|
|
15
|
+
export const TEXTURE_STYLES = ["noise", "marble", "wood", "bricks", "checker", "bark", "spots", "stripes", "solid"];
|
|
16
|
+
|
|
17
|
+
/** Default colors for each style: [first, second]. */
|
|
18
|
+
const DEFAULT_COLORS = {
|
|
19
|
+
noise: ["#3a3f4b", "#c9ced6"],
|
|
20
|
+
marble: ["#e9e6df", "#5d5a55"],
|
|
21
|
+
wood: ["#8a5a33", "#5a3820"],
|
|
22
|
+
bricks: ["#9c4a35", "#cfc8bb"],
|
|
23
|
+
checker: ["#f5f0e6", "#E9806E"],
|
|
24
|
+
bark: ["#4a3426", "#2a1d15"],
|
|
25
|
+
spots: ["#e8c88f", "#5a3d22"],
|
|
26
|
+
stripes: ["#f2efe8", "#4a6fa5"],
|
|
27
|
+
solid: ["#c0c0c0", "#c0c0c0"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const clamp01 = (t) => Math.min(1, Math.max(0, t));
|
|
31
|
+
const smooth = (a, b, t) => {
|
|
32
|
+
const x = clamp01((t - a) / (b - a));
|
|
33
|
+
return x * x * (3 - 2 * x);
|
|
34
|
+
};
|
|
35
|
+
const even = (n) => Math.max(2, Math.round(n / 2) * 2);
|
|
36
|
+
const frac = (x) => x - Math.floor(x);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* For a style, a function of (u, v) in [0, 1) giving `[t, light]`: how much
|
|
40
|
+
* of the second color to use (0..1), and a brightness to multiply by.
|
|
41
|
+
*/
|
|
42
|
+
function styleFn(style, s, seed) {
|
|
43
|
+
// Noise with `period` p repeats every p units: sampled at u * (a multiple of p), it tiles
|
|
44
|
+
const f = (x, y, octaves, period, k = 0) => fbm2(x, y, { octaves, period, seed: seed + k });
|
|
45
|
+
switch (style) {
|
|
46
|
+
case "solid":
|
|
47
|
+
return () => [0, 1];
|
|
48
|
+
case "noise":
|
|
49
|
+
return (u, v) => [smooth(0.25, 0.75, f(u * s, v * s, 5, s)), 1];
|
|
50
|
+
case "marble":
|
|
51
|
+
return (u, v) => {
|
|
52
|
+
const turb = f(u * s, v * s, 6, s);
|
|
53
|
+
const t = 0.5 + 0.5 * Math.sin(((u + v) * s + turb * 2.5) * Math.PI * 2);
|
|
54
|
+
return [Math.pow(t, 6), 1];
|
|
55
|
+
};
|
|
56
|
+
case "wood":
|
|
57
|
+
return (u, v) => {
|
|
58
|
+
const warp = f(u * s, v * s, 4, s) * 3;
|
|
59
|
+
const ring = frac(u * s * 2 + warp);
|
|
60
|
+
// Fine grain along the boards (64 and 4 are both multiples of the period 4)
|
|
61
|
+
return [smooth(0.55, 0.95, ring), 0.9 + 0.1 * f(u * 64, v * 4, 2, 4, 7)];
|
|
62
|
+
};
|
|
63
|
+
case "bricks": {
|
|
64
|
+
const rows = even(s * 2), cols = s;
|
|
65
|
+
return (u, v) => {
|
|
66
|
+
const row = Math.floor(v * rows);
|
|
67
|
+
const x = u * cols + (row % 2) * 0.5;
|
|
68
|
+
const fx = frac(x), fy = v * rows - row;
|
|
69
|
+
if (Math.min(fx, 1 - fx) < 0.04 || Math.min(fy, 1 - fy) < 0.08) return [1, 1]; // mortar
|
|
70
|
+
// Each brick its own shade (bricks cut by the edge are the same brick on the other side)
|
|
71
|
+
const brick = ((Math.floor(x) % cols) + cols) % cols;
|
|
72
|
+
const tint = 0.85 + 0.3 * noise2(brick, row, { seed });
|
|
73
|
+
return [0.15 * f(u * 32, v * 32, 3, 32), tint];
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
case "checker": {
|
|
77
|
+
const c = even(s);
|
|
78
|
+
return (u, v) => [(Math.floor(u * c) + Math.floor(v * c)) % 2, 1];
|
|
79
|
+
}
|
|
80
|
+
case "bark":
|
|
81
|
+
return (u, v) => {
|
|
82
|
+
const ridges = Math.abs(f(u * s * 3, v * s, 5, s) * 2 - 1);
|
|
83
|
+
return [smooth(0.05, 0.4, ridges), 0.8 + 0.4 * ridges];
|
|
84
|
+
};
|
|
85
|
+
case "spots":
|
|
86
|
+
return (u, v) => [smooth(0.55, 0.62, f(u * s, v * s, 4, s)), 1];
|
|
87
|
+
case "stripes":
|
|
88
|
+
return (u, v) => [smooth(0.45, 0.55, 0.5 + 0.5 * Math.sin((v * s + f(u * 4, v * 4, 3, 4) * 0.4) * Math.PI * 2)), 1];
|
|
89
|
+
default:
|
|
90
|
+
throw new Error(`makeTexture: unknown style "${style}" (one of ${TEXTURE_STYLES.join(", ")}, or a function)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** [r, g, b, a] (0..255, a 0..1) from "#rgb", "#rrggbb", "#rrggbbaa", "rgb(...)" / "rgba(...)" or an array. */
|
|
95
|
+
export function colorBytes(color) {
|
|
96
|
+
if (Array.isArray(color)) return [color[0] ?? 0, color[1] ?? 0, color[2] ?? 0, color[3] ?? 1];
|
|
97
|
+
const s = String(color).trim();
|
|
98
|
+
if (s[0] === "#") {
|
|
99
|
+
let h = s.slice(1);
|
|
100
|
+
if (h.length === 3 || h.length === 4) h = h.replace(/./g, "$&$&");
|
|
101
|
+
const v = (i) => parseInt(h.slice(i, i + 2), 16);
|
|
102
|
+
return [v(0), v(2), v(4), h.length === 8 ? v(6) / 255 : 1];
|
|
103
|
+
}
|
|
104
|
+
const nums = s.match(/-?[\d.]+%?/g);
|
|
105
|
+
if (/^rgba?\(/i.test(s) && nums && nums.length >= 3) {
|
|
106
|
+
const ch = (x) => (x.endsWith("%") ? (parseFloat(x) * 255) / 100 : +x);
|
|
107
|
+
const a = nums[3] === undefined ? 1 : nums[3].endsWith("%") ? parseFloat(nums[3]) / 100 : +nums[3];
|
|
108
|
+
return [ch(nums[0]), ch(nums[1]), ch(nums[2]), a];
|
|
109
|
+
}
|
|
110
|
+
// Named CSS colors need the browser to read them
|
|
111
|
+
if (typeof document !== "undefined") {
|
|
112
|
+
const ctx = document.createElement("canvas").getContext("2d");
|
|
113
|
+
if (ctx) {
|
|
114
|
+
ctx.fillStyle = "#000";
|
|
115
|
+
ctx.fillStyle = s;
|
|
116
|
+
if (ctx.fillStyle !== s) return colorBytes(ctx.fillStyle);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new Error(`Can't read the color "${color}"`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The pixels of a procedural texture, without a canvas (for workers, or to
|
|
124
|
+
* process them further). Same options as `makeTexture`.
|
|
125
|
+
*
|
|
126
|
+
* @param {string|((u:number, v:number) => number)} style - A style name, or `(u, v) => t`
|
|
127
|
+
* @param {Object} [opts] - See `makeTexture`
|
|
128
|
+
* @returns {{ width:number, height:number, data:Uint8ClampedArray }} RGBA rows, top row first
|
|
129
|
+
*/
|
|
130
|
+
export function texturePixels(style = "noise", opts = {}) {
|
|
131
|
+
const {
|
|
132
|
+
size = 256,
|
|
133
|
+
colors = typeof style === "string" ? DEFAULT_COLORS[style] ?? DEFAULT_COLORS.noise : ["#000000", "#ffffff"],
|
|
134
|
+
scale = 4,
|
|
135
|
+
seed = 0,
|
|
136
|
+
grain = 0,
|
|
137
|
+
} = opts;
|
|
138
|
+
const width = Math.max(1, Math.floor(size)), height = width;
|
|
139
|
+
const s = Math.max(1, Math.round(scale));
|
|
140
|
+
const a = colorBytes(colors[0]), b = colorBytes(colors[1] ?? colors[0]);
|
|
141
|
+
const fn = typeof style === "function" ? (u, v) => [clamp01(style(u, v)), 1] : styleFn(style, s, seed);
|
|
142
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
143
|
+
|
|
144
|
+
for (let y = 0; y < height; y++) {
|
|
145
|
+
// Image rows go down, texture v goes up
|
|
146
|
+
const v = 1 - (y + 0.5) / height;
|
|
147
|
+
for (let x = 0; x < width; x++) {
|
|
148
|
+
const u = (x + 0.5) / width;
|
|
149
|
+
const [t, light] = fn(u, v);
|
|
150
|
+
const g = grain > 0 ? 1 + (fbm2(u * 128, v * 128, { octaves: 2, period: 128, seed: seed + 99 }) - 0.5) * grain : 1;
|
|
151
|
+
const k = (y * width + x) * 4;
|
|
152
|
+
for (let c = 0; c < 3; c++) data[k + c] = (a[c] + (b[c] - a[c]) * t) * light * g;
|
|
153
|
+
data[k + 3] = (a[3] + (b[3] - a[3]) * t) * 255;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { width, height, data };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Paints a procedural texture on a canvas: wood, marble, bricks... Every
|
|
161
|
+
* style tiles with no seam. Pass the result as a material's `texture`.
|
|
162
|
+
*
|
|
163
|
+
* | Style | Looks like | `scale` is |
|
|
164
|
+
* | --- | --- | --- |
|
|
165
|
+
* | `"noise"` | Soft clouds | Blobs across |
|
|
166
|
+
* | `"marble"` | Veined stone | Veins across |
|
|
167
|
+
* | `"wood"` | Boards with rings and grain (rings run along v) | Rings across, ×2 |
|
|
168
|
+
* | `"bricks"` | A brick wall, each brick its own shade | Bricks across (rows: 2× as many) |
|
|
169
|
+
* | `"checker"` | A checkerboard | Squares across (made even) |
|
|
170
|
+
* | `"bark"` | Rough ridges running along v | Ridges across, ×3 |
|
|
171
|
+
* | `"spots"` | Blotches, like a giraffe | Spots across |
|
|
172
|
+
* | `"stripes"` | Wavy stripes across v | Stripes |
|
|
173
|
+
* | `"solid"` | Just the first color (with `grain`: a rough surface) | — |
|
|
174
|
+
*
|
|
175
|
+
* `style` can also be your own function `(u, v) => t`: 0 gives the first
|
|
176
|
+
* color, 1 the second. It should repeat for u and v in [0, 1) to tile.
|
|
177
|
+
*
|
|
178
|
+
* Repainting into the same canvas (`opts.canvas`) calls `updateTexture` for you.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* const floor = makeTexture("wood", { colors: ["#a87447", "#6b4426"], scale: 3, grain: 0.15 });
|
|
182
|
+
* scene.add({ mesh: makeShape("plane"), material: { texture: floor, textureRepeat: 4 } });
|
|
183
|
+
*
|
|
184
|
+
* @param {string|((u:number, v:number) => number)} [style="noise"] - One of `TEXTURE_STYLES`, or a function
|
|
185
|
+
* @param {Object} [opts]
|
|
186
|
+
* @param {number} [opts.size=256] - Width and height in pixels (a power of two is best for WebGL 1)
|
|
187
|
+
* @param {(string|number[])[]} [opts.colors] - [first, second]: hex, rgb() / rgba(), CSS names or [r, g, b, a?]
|
|
188
|
+
* (default: a pair that suits the style)
|
|
189
|
+
* @param {number} [opts.scale=4] - How many times the pattern repeats across (a whole number)
|
|
190
|
+
* @param {number} [opts.seed=0] - Another whole number gives another texture of the same style
|
|
191
|
+
* @param {number} [opts.grain=0] - Fine speckles on top, 0 to 1
|
|
192
|
+
* @param {HTMLCanvasElement|OffscreenCanvas} [opts.canvas] - Paint into this canvas instead of a new one
|
|
193
|
+
* @returns {HTMLCanvasElement|OffscreenCanvas} The canvas
|
|
194
|
+
*/
|
|
195
|
+
export function makeTexture(style = "noise", opts = {}) {
|
|
196
|
+
const { width, height, data } = texturePixels(style, opts);
|
|
197
|
+
let canvas = opts.canvas;
|
|
198
|
+
if (!canvas) {
|
|
199
|
+
if (typeof document !== "undefined") canvas = document.createElement("canvas");
|
|
200
|
+
else if (typeof OffscreenCanvas !== "undefined") canvas = new OffscreenCanvas(width, height);
|
|
201
|
+
else throw new Error("makeTexture needs a browser (or a worker with OffscreenCanvas); use texturePixels instead");
|
|
202
|
+
}
|
|
203
|
+
canvas.width = width;
|
|
204
|
+
canvas.height = height;
|
|
205
|
+
const ctx = canvas.getContext("2d");
|
|
206
|
+
const img = ctx.createImageData(width, height);
|
|
207
|
+
img.data.set(data);
|
|
208
|
+
ctx.putImageData(img, 0, 0);
|
|
209
|
+
if (opts.canvas) updateTexture(canvas);
|
|
210
|
+
return canvas;
|
|
211
|
+
}
|