trid3d 0.2.0 → 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 +8 -7
- package/package.json +1 -1
- package/trid/export/mtl.js +119 -0
- package/trid/export/obj.js +107 -21
- package/trid/index.d.ts +417 -8
- package/trid/index.js +19 -3
- package/trid/loaders/mtl.js +63 -0
- package/trid/loaders/obj.js +58 -6
- 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 +40 -23
- package/trid/render/draw.js +6 -2
- package/trid/render/meshUtils.js +257 -0
- package/trid/render/proceduralTextures.js +211 -0
- package/trid/render/shapes.js +95 -27
- package/trid/render/webglRenderer.js +98 -27
- package/trid/scene.js +20 -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 +4 -2
- package/trid/tools/index.js +3 -1
- package/trid/tools/orbitCamera.js +9 -0
- package/trid/tools/transformGizmo.js +316 -0
- package/trid/tools/viewGizmo.js +178 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } from "../math/transform.js";
|
|
2
|
+
import { transformPoint, normalMatrix, transformNormal, determinantMat4 } from "../math/matrix.js";
|
|
3
|
+
import { worldObjects } from "../sceneGraph.js";
|
|
2
4
|
import { clear, drawInstance, edgeSegments, wireEdges } from "./draw.js";
|
|
3
5
|
import { parseColor } from "./color.js";
|
|
4
6
|
import { newell, cornerNormals, creaseOf } from "./normals.js";
|
|
5
|
-
import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
|
|
7
|
+
import { textureReady, textureSize, textureIsLive, textureVersion } from "../loaders/texture.js";
|
|
6
8
|
import { uvsFor, uvTransform, applyUVTransform } from "./uvMapping.js";
|
|
7
9
|
|
|
8
10
|
export { newell };
|
|
@@ -44,6 +46,8 @@ export const DEFAULT_AMBIENT = 0.25;
|
|
|
44
46
|
* shapes look round. `true` keeps edges sharper than 60° hard; a number sets that angle.
|
|
45
47
|
* @property {CanvasImageSource} [texture] - Image drawn on the faces (needs texture coordinates:
|
|
46
48
|
* built-in shapes and OBJ files with `vt` have them). `fill` tints it (default white).
|
|
49
|
+
* @property {boolean} [liveTexture] - Read the texture again every frame (default: only for videos).
|
|
50
|
+
* For a canvas redrawn every frame; for one redrawn now and then, call `updateTexture(canvas)` instead
|
|
47
51
|
* @property {boolean} [pixelated=false] - Keep texture pixels sharp instead of smoothing them
|
|
48
52
|
* @property {import("./uvMapping.js").Mapping} [mapping="uv"] - Where texture coordinates come
|
|
49
53
|
* from: the mesh's own ("uv"), or made from its shape ("planar", "box", "sphere", "cylinder")
|
|
@@ -86,7 +90,7 @@ export class CanvasRenderer {
|
|
|
86
90
|
this._splits = new WeakMap();
|
|
87
91
|
this._colors = new Map();
|
|
88
92
|
this._start = performance.now();
|
|
89
|
-
this._patterns = new
|
|
93
|
+
this._patterns = new WeakMap(); // texture source → canvas pattern (freed with the source)
|
|
90
94
|
this._warned = new WeakSet();
|
|
91
95
|
this._frame = 0;
|
|
92
96
|
}
|
|
@@ -102,11 +106,15 @@ export class CanvasRenderer {
|
|
|
102
106
|
clear(ctx, w, h, scene.background);
|
|
103
107
|
this._frame++;
|
|
104
108
|
|
|
109
|
+
// Every object with a mesh, groups' children included, with its world matrix
|
|
110
|
+
const drawn = worldObjects(scene).filter((o) => o.mesh);
|
|
111
|
+
|
|
105
112
|
// Wireframes only: nothing to sort
|
|
106
|
-
if (!
|
|
107
|
-
for (const obj of
|
|
108
|
-
drawInstance(ctx, obj, camera, w, h, obj.color ?? scene.color, obj.lineWidth ?? scene.lineWidth);
|
|
113
|
+
if (!drawn.some((o) => materialsOf(o))) {
|
|
114
|
+
for (const obj of drawn) {
|
|
115
|
+
drawInstance(ctx, obj, camera, w, h, obj.color ?? scene.color, obj.lineWidth ?? scene.lineWidth, obj.world);
|
|
109
116
|
}
|
|
117
|
+
for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
|
|
110
118
|
return;
|
|
111
119
|
}
|
|
112
120
|
|
|
@@ -120,12 +128,12 @@ export class CanvasRenderer {
|
|
|
120
128
|
const time = (performance.now() - this._start) / 1000;
|
|
121
129
|
const background = this._rgba(ctx, scene.background);
|
|
122
130
|
|
|
123
|
-
for (const obj of
|
|
131
|
+
for (const obj of drawn) {
|
|
124
132
|
const mats = materialsOf(obj);
|
|
125
133
|
// Split big faces (cached per mesh); extra vertices are midpoints of existing ones
|
|
126
134
|
const split = mats && this.maxFaceSize ? this._split(obj.mesh) : null;
|
|
127
135
|
const verts = split ? withMidpoints(obj.mesh.vertices, split.mids) : obj.mesh.vertices;
|
|
128
|
-
const world = verts.map((v) =>
|
|
136
|
+
const world = verts.map((v) => transformPoint(obj.world, v));
|
|
129
137
|
const cam = world.map((p) => worldToCamera(p, camera));
|
|
130
138
|
const color = obj.color ?? scene.color;
|
|
131
139
|
const lineWidth = obj.lineWidth ?? scene.lineWidth;
|
|
@@ -145,6 +153,13 @@ export class CanvasRenderer {
|
|
|
145
153
|
|
|
146
154
|
const sourceFaces = obj.mesh.faces ?? [];
|
|
147
155
|
const base = obj.mesh.vertices.length;
|
|
156
|
+
// Normals turn with the object (stretched and mirrored ones too); a mirror reverses face winding
|
|
157
|
+
const nm = normalMatrix(obj.world);
|
|
158
|
+
const flip = determinantMat4(obj.world) < 0 ? -1 : 1;
|
|
159
|
+
const flatNormal = (face) => {
|
|
160
|
+
const n = normalize(newell(face.map((i) => world[i])));
|
|
161
|
+
return { x: n.x * flip, y: n.y * flip, z: n.z * flip };
|
|
162
|
+
};
|
|
148
163
|
|
|
149
164
|
// Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
|
|
150
165
|
const normalAt = (corners, F, i) => {
|
|
@@ -154,18 +169,16 @@ export class CanvasRenderer {
|
|
|
154
169
|
return normalize({ x: na.x + nb.x, y: na.y + nb.y, z: na.z + nb.z });
|
|
155
170
|
};
|
|
156
171
|
const lightAt = (n, back) => {
|
|
157
|
-
|
|
158
|
-
const r = normalize(rotateXYZ({ x: n.x / obj.scale.x, y: n.y / obj.scale.y, z: n.z / obj.scale.z }, obj.rot));
|
|
172
|
+
const r = transformNormal(nm, n);
|
|
159
173
|
const d = (back ? -1 : 1) * (r.x * light.x + r.y * light.y + r.z * light.z);
|
|
160
174
|
return ambient + (1 - ambient) * Math.max(0, d);
|
|
161
175
|
};
|
|
162
176
|
const flatLight = (face, back) => {
|
|
163
|
-
const nw =
|
|
177
|
+
const nw = flatNormal(face);
|
|
164
178
|
const d = (back ? -1 : 1) * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
|
|
165
179
|
return ambient + (1 - ambient) * Math.max(0, d);
|
|
166
180
|
};
|
|
167
|
-
const toWorldNormal = (n) =>
|
|
168
|
-
normalize(rotateXYZ({ x: n.x / obj.scale.x, y: n.y / obj.scale.y, z: n.z / obj.scale.z }, obj.rot));
|
|
181
|
+
const toWorldNormal = (n) => transformNormal(nm, n);
|
|
169
182
|
|
|
170
183
|
// Everything a material needs, worked out once per object and frame
|
|
171
184
|
const prepare = (mat) => {
|
|
@@ -220,7 +233,7 @@ export class CanvasRenderer {
|
|
|
220
233
|
// Seen from behind? (camera sits at the origin of camera space)
|
|
221
234
|
const n = newell(pts);
|
|
222
235
|
const c = centroid(pts);
|
|
223
|
-
const back = n.x * c.x + n.y * c.y + n.z * c.z >= 0;
|
|
236
|
+
const back = (n.x * c.x + n.y * c.y + n.z * c.z >= 0) !== (flip < 0);
|
|
224
237
|
if (back && !L.mat.doubleSided) continue;
|
|
225
238
|
|
|
226
239
|
const clipped = clipPolygon(pts, near);
|
|
@@ -244,7 +257,7 @@ export class CanvasRenderer {
|
|
|
244
257
|
items.push({
|
|
245
258
|
depth: c.z,
|
|
246
259
|
poly: [screen[0], screen[k], screen[k + 1]],
|
|
247
|
-
texture: { src: L.tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!L.mat.pixelated, tint: fill },
|
|
260
|
+
texture: { src: L.tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!L.mat.pixelated, live: textureIsLive(L.tex, L.mat), tint: fill },
|
|
248
261
|
lit: [lit[0], lit[k], lit[k + 1]],
|
|
249
262
|
outline, lineWidth,
|
|
250
263
|
edges: n === 3 ? edges : fan,
|
|
@@ -263,7 +276,7 @@ export class CanvasRenderer {
|
|
|
263
276
|
.reduce((a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }), { x: 0, y: 0, z: 0 });
|
|
264
277
|
nw = normalize(sum);
|
|
265
278
|
} else {
|
|
266
|
-
nw =
|
|
279
|
+
nw = flatNormal(face);
|
|
267
280
|
}
|
|
268
281
|
if (back) nw = { x: -nw.x, y: -nw.y, z: -nw.z };
|
|
269
282
|
|
|
@@ -380,7 +393,7 @@ export class CanvasRenderer {
|
|
|
380
393
|
}
|
|
381
394
|
|
|
382
395
|
// Meshes marked as changed have been redrawn from their new data
|
|
383
|
-
for (const obj of
|
|
396
|
+
for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
|
|
384
397
|
}
|
|
385
398
|
|
|
386
399
|
/**
|
|
@@ -493,7 +506,7 @@ export class CanvasRenderer {
|
|
|
493
506
|
* triangle's corners, tinted by the fill color, then darkened by the light.
|
|
494
507
|
*/
|
|
495
508
|
_drawTextured(ctx, it) {
|
|
496
|
-
const { src, uvs, pixelated, tint } = it.texture;
|
|
509
|
+
const { src, uvs, pixelated, live, tint } = it.texture;
|
|
497
510
|
const { w: W, h: H } = textureSize(src);
|
|
498
511
|
const [p0, p1, p2] = it.poly;
|
|
499
512
|
|
|
@@ -519,7 +532,7 @@ export class CanvasRenderer {
|
|
|
519
532
|
const dx1 = p1.x - p0.x, dy1 = p1.y - p0.y, dx2 = p2.x - p0.x, dy2 = p2.y - p0.y;
|
|
520
533
|
const a = (dx1 * dv2 - dx2 * dv1) / det, b = (dy1 * dv2 - dy2 * dv1) / det;
|
|
521
534
|
const c = (dx2 * du1 - dx1 * du2) / det, d = (dy2 * du1 - dy1 * du2) / det;
|
|
522
|
-
const pattern = this._pattern(ctx, src);
|
|
535
|
+
const pattern = this._pattern(ctx, src, live);
|
|
523
536
|
pattern.setTransform(new DOMMatrix([a, b, c, d, p0.x - a * U[0] - c * V[0], p0.y - b * U[0] - d * V[0]]));
|
|
524
537
|
ctx.imageSmoothingEnabled = !pixelated;
|
|
525
538
|
ctx.fillStyle = pattern;
|
|
@@ -553,11 +566,15 @@ export class CanvasRenderer {
|
|
|
553
566
|
ctx.fill();
|
|
554
567
|
}
|
|
555
568
|
|
|
556
|
-
/**
|
|
557
|
-
|
|
569
|
+
/**
|
|
570
|
+
* A repeating pattern for a texture. A pattern is a copy of the image, so
|
|
571
|
+
* it's made again after `updateTexture`, and every frame for live ones.
|
|
572
|
+
*/
|
|
573
|
+
_pattern(ctx, src, live) {
|
|
558
574
|
let p = this._patterns.get(src);
|
|
559
|
-
|
|
560
|
-
|
|
575
|
+
const version = textureVersion(src);
|
|
576
|
+
if (!p || p.version !== version || (live && p.frame !== this._frame)) {
|
|
577
|
+
p = { pattern: ctx.createPattern(src, "repeat"), frame: this._frame, version };
|
|
561
578
|
this._patterns.set(src, p);
|
|
562
579
|
}
|
|
563
580
|
return p.pattern;
|
package/trid/render/draw.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { localToWorld, worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } from "../math/transform.js";
|
|
2
|
+
import { transformPoint } from "../math/matrix.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Clears the entire canvas with a solid background color.
|
|
@@ -136,10 +137,13 @@ export function drawWireframe(ctx, points2, edges, color, width = 4) {
|
|
|
136
137
|
* @param {number} h - Canvas height
|
|
137
138
|
* @param {string} color - Wireframe color
|
|
138
139
|
* @param {number} lineWidth - Wireframe width
|
|
140
|
+
* @param {number[]} [world] - World matrix to place it with (e.g. `obj.world` inside a group).
|
|
141
|
+
* Default: the instance's own pos / rot / scale
|
|
139
142
|
*/
|
|
140
|
-
export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3) {
|
|
143
|
+
export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3, world) {
|
|
141
144
|
const near = camera.near ?? DEFAULT_NEAR;
|
|
142
|
-
const
|
|
145
|
+
const toWorld = world ? (v) => transformPoint(world, v) : (v) => localToWorld(v, inst);
|
|
146
|
+
const ptsCam = inst.mesh.vertices.map((v) => worldToCamera(toWorld(v), camera));
|
|
143
147
|
|
|
144
148
|
ctx.lineWidth = lineWidth;
|
|
145
149
|
ctx.strokeStyle = color;
|
|
@@ -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
|
+
}
|