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,152 @@
|
|
|
1
|
+
import { rotateXYZ } from "./transform.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rotation matrices and move / rotate / scale operations on an object's
|
|
5
|
+
* transform ({ pos, rot, scale }, applied scale → rotate X, Y, Z → move).
|
|
6
|
+
*
|
|
7
|
+
* Matrices are 3×3, row-major (`m[row][col]`), applied as M · v. They're
|
|
8
|
+
* built from `rotateXYZ` itself, so they always match what the renderers draw.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {[[number,number,number],[number,number,number],[number,number,number]]} Mat3
|
|
11
|
+
* @typedef {{x:number, y:number, z:number}} Vec3
|
|
12
|
+
* @typedef {{ pos:Vec3, rot:Vec3, scale:Vec3 }} Transform
|
|
13
|
+
*
|
|
14
|
+
* @typedef {{ mode:"move", delta:Vec3 }
|
|
15
|
+
* | { mode:"rotate", axis:Vec3, angle:number, center:Vec3 }
|
|
16
|
+
* | { mode:"scale", axis:Vec3|null, factor:number, center:Vec3 }} TransformOp
|
|
17
|
+
* An operation in world space. Rotate: a right-handed turn of `angle`
|
|
18
|
+
* radians around the unit `axis`, about `center`. Scale: `factor` about
|
|
19
|
+
* `center`, uniform when `axis` is null, otherwise along that unit axis.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
|
23
|
+
const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z });
|
|
24
|
+
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
|
|
25
|
+
const mul = (a, k) => ({ x: a.x * k, y: a.y * k, z: a.z * k });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The matrix `rotateXYZ(p, rot)` applies.
|
|
29
|
+
*
|
|
30
|
+
* @param {Vec3} rot - Angles in radians (X, then Y, then Z)
|
|
31
|
+
* @returns {Mat3}
|
|
32
|
+
*/
|
|
33
|
+
export function matFromEuler(rot) {
|
|
34
|
+
const cx = rotateXYZ({ x: 1, y: 0, z: 0 }, rot);
|
|
35
|
+
const cy = rotateXYZ({ x: 0, y: 1, z: 0 }, rot);
|
|
36
|
+
const cz = rotateXYZ({ x: 0, y: 0, z: 1 }, rot);
|
|
37
|
+
return [
|
|
38
|
+
[cx.x, cy.x, cz.x],
|
|
39
|
+
[cx.y, cy.y, cz.y],
|
|
40
|
+
[cx.z, cy.z, cz.z],
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Angles for `rotateXYZ` (an object's `rot`) from a rotation matrix: the
|
|
46
|
+
* inverse of `matFromEuler`. Looking straight along Y (gimbal lock), X and
|
|
47
|
+
* Z turn the same way, so the whole turn goes into Z.
|
|
48
|
+
*
|
|
49
|
+
* @param {Mat3} m - A rotation matrix
|
|
50
|
+
* @returns {Vec3} Angles in radians
|
|
51
|
+
*/
|
|
52
|
+
export function eulerFromMat(m) {
|
|
53
|
+
// rotateXYZ's matrix is Rz·Ry·Rx: m[2][0] = sin(y), m[2][1] = cos(y)·sin(x), m[1][0] = sin(z)·cos(y)
|
|
54
|
+
const sy = Math.max(-1, Math.min(1, m[2][0]));
|
|
55
|
+
const y = Math.asin(sy);
|
|
56
|
+
if (Math.abs(sy) < 0.99999) {
|
|
57
|
+
return { x: Math.atan2(m[2][1], m[2][2]), y, z: Math.atan2(m[1][0], m[0][0]) };
|
|
58
|
+
}
|
|
59
|
+
return { x: 0, y, z: Math.atan2(-m[0][1], m[1][1]) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A right-handed turn of `angle` radians around a unit `axis` (Rodrigues).
|
|
64
|
+
*
|
|
65
|
+
* @param {Vec3} axis - Unit vector
|
|
66
|
+
* @param {number} angle - Radians
|
|
67
|
+
* @returns {Mat3}
|
|
68
|
+
*/
|
|
69
|
+
export function matFromAxisAngle(axis, angle) {
|
|
70
|
+
const { x, y, z } = axis, c = Math.cos(angle), s = Math.sin(angle), t = 1 - c;
|
|
71
|
+
return [
|
|
72
|
+
[t * x * x + c, t * x * y - s * z, t * x * z + s * y],
|
|
73
|
+
[t * x * y + s * z, t * y * y + c, t * y * z - s * x],
|
|
74
|
+
[t * x * z - s * y, t * y * z + s * x, t * z * z + c],
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Matrix product a · b (b is applied first).
|
|
80
|
+
*
|
|
81
|
+
* @param {Mat3} a
|
|
82
|
+
* @param {Mat3} b
|
|
83
|
+
* @returns {Mat3}
|
|
84
|
+
*/
|
|
85
|
+
export function mulMat(a, b) {
|
|
86
|
+
return [0, 1, 2].map((i) => [0, 1, 2].map((j) => a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Matrix times vector, m · v.
|
|
91
|
+
*
|
|
92
|
+
* @param {Mat3} m
|
|
93
|
+
* @param {Vec3} v
|
|
94
|
+
* @returns {Vec3}
|
|
95
|
+
*/
|
|
96
|
+
export function mulVec(m, v) {
|
|
97
|
+
return {
|
|
98
|
+
x: m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z,
|
|
99
|
+
y: m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z,
|
|
100
|
+
z: m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Applies a world-space operation to a transform and returns the new one
|
|
106
|
+
* (the input isn't changed). `TransformGizmo` reports these.
|
|
107
|
+
*
|
|
108
|
+
* - move: adds `delta` to `pos`
|
|
109
|
+
* - rotate: turns the object around `center`: its position orbits and its
|
|
110
|
+
* orientation turns, both by the same rotation
|
|
111
|
+
* - scale: scales about `center`. Stretching a rotated object along a world
|
|
112
|
+
* axis can't be stored as `scale` (it would shear), so it stretches the
|
|
113
|
+
* object's own axis that points most along it
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* Object.assign(obj, applyTransformOp(obj, { mode: "rotate", axis: { x: 0, y: 1, z: 0 }, angle: Math.PI / 2, center: { x: 0, y: 0, z: 0 } }));
|
|
117
|
+
*
|
|
118
|
+
* @param {Transform} t - The transform before the operation
|
|
119
|
+
* @param {TransformOp} op
|
|
120
|
+
* @returns {Transform}
|
|
121
|
+
*/
|
|
122
|
+
export function applyTransformOp(t, op) {
|
|
123
|
+
switch (op.mode) {
|
|
124
|
+
case "move":
|
|
125
|
+
return { pos: add(t.pos, op.delta), rot: { ...t.rot }, scale: { ...t.scale } };
|
|
126
|
+
case "rotate": {
|
|
127
|
+
const r = matFromAxisAngle(op.axis, op.angle);
|
|
128
|
+
return {
|
|
129
|
+
pos: add(op.center, mulVec(r, sub(t.pos, op.center))),
|
|
130
|
+
rot: eulerFromMat(mulMat(r, matFromEuler(t.rot))),
|
|
131
|
+
scale: { ...t.scale },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
case "scale": {
|
|
135
|
+
const f = op.factor;
|
|
136
|
+
if (!op.axis) {
|
|
137
|
+
return { pos: add(op.center, mul(sub(t.pos, op.center), f)), rot: { ...t.rot }, scale: mul(t.scale, f) };
|
|
138
|
+
}
|
|
139
|
+
const a = op.axis;
|
|
140
|
+
const pos = add(t.pos, mul(a, (f - 1) * dot(sub(t.pos, op.center), a)));
|
|
141
|
+
// The object's own axis most aligned with the stretch (columns of its rotation)
|
|
142
|
+
const m = matFromEuler(t.rot);
|
|
143
|
+
const along = [0, 1, 2].map((j) => Math.abs(m[0][j] * a.x + m[1][j] * a.y + m[2][j] * a.z));
|
|
144
|
+
const j = along.indexOf(Math.max(...along));
|
|
145
|
+
const scale = { ...t.scale };
|
|
146
|
+
scale["xyz"[j]] *= f;
|
|
147
|
+
return { pos, rot: { ...t.rot }, scale };
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
throw new Error("applyTransformOp: unknown mode " + op?.mode);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -1,11 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
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";
|
|
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";
|
|
8
|
+
import { uvsFor, uvTransform, applyUVTransform } from "./uvMapping.js";
|
|
6
9
|
|
|
7
10
|
export { newell };
|
|
8
11
|
|
|
12
|
+
/**
|
|
13
|
+
* An object's materials as a list (`material` can be one material or a
|
|
14
|
+
* list of them, picked per face by `mesh.faceMaterials`). Null when the
|
|
15
|
+
* object is a wireframe.
|
|
16
|
+
*
|
|
17
|
+
* @returns {Material[]|null}
|
|
18
|
+
*/
|
|
19
|
+
export function materialsOf(obj) {
|
|
20
|
+
const m = obj.material;
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
if (!Array.isArray(m)) return [m];
|
|
23
|
+
return m.length && m[0] ? m : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
9
26
|
/** Direction light comes from when the scene doesn't set one: above, left, in front. */
|
|
10
27
|
export const DEFAULT_LIGHT = { x: -0.5, y: 1, z: -0.8 };
|
|
11
28
|
|
|
@@ -16,6 +33,10 @@ export const DEFAULT_AMBIENT = 0.25;
|
|
|
16
33
|
* Material: how an object's faces look. Objects without one are drawn
|
|
17
34
|
* as wireframes using their `color` and `lineWidth`.
|
|
18
35
|
*
|
|
36
|
+
* An object's `material` can also be a list: each face then uses the
|
|
37
|
+
* entry `mesh.faceMaterials[face]` points at (the first one by default).
|
|
38
|
+
* Built-in cubes and boxes number their sides 0 to 5.
|
|
39
|
+
*
|
|
19
40
|
* @typedef {Object} Material
|
|
20
41
|
* @property {string} [fill] - Face color, any CSS color (default: the scene's color)
|
|
21
42
|
* @property {boolean} [shade=true] - Darken faces that turn away from the light
|
|
@@ -25,7 +46,14 @@ export const DEFAULT_AMBIENT = 0.25;
|
|
|
25
46
|
* shapes look round. `true` keeps edges sharper than 60° hard; a number sets that angle.
|
|
26
47
|
* @property {CanvasImageSource} [texture] - Image drawn on the faces (needs texture coordinates:
|
|
27
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
|
|
28
51
|
* @property {boolean} [pixelated=false] - Keep texture pixels sharp instead of smoothing them
|
|
52
|
+
* @property {import("./uvMapping.js").Mapping} [mapping="uv"] - Where texture coordinates come
|
|
53
|
+
* from: the mesh's own ("uv"), or made from its shape ("planar", "box", "sphere", "cylinder")
|
|
54
|
+
* @property {number|{u:number,v:number}} [textureRepeat=1] - How many times the image repeats across
|
|
55
|
+
* @property {{u:number,v:number}} [textureOffset] - Moves the image (1 = its whole width or height)
|
|
56
|
+
* @property {number} [textureRotation=0] - Turns the image around its center, in radians
|
|
29
57
|
* @property {Object[]} [effects] - Effects applied in order, like `[toon(), rim()]`.
|
|
30
58
|
* See trid/render/effects.js. The canvas renderer runs them once per face.
|
|
31
59
|
*/
|
|
@@ -62,7 +90,7 @@ export class CanvasRenderer {
|
|
|
62
90
|
this._splits = new WeakMap();
|
|
63
91
|
this._colors = new Map();
|
|
64
92
|
this._start = performance.now();
|
|
65
|
-
this._patterns = new
|
|
93
|
+
this._patterns = new WeakMap(); // texture source → canvas pattern (freed with the source)
|
|
66
94
|
this._warned = new WeakSet();
|
|
67
95
|
this._frame = 0;
|
|
68
96
|
}
|
|
@@ -78,11 +106,15 @@ export class CanvasRenderer {
|
|
|
78
106
|
clear(ctx, w, h, scene.background);
|
|
79
107
|
this._frame++;
|
|
80
108
|
|
|
109
|
+
// Every object with a mesh, groups' children included, with its world matrix
|
|
110
|
+
const drawn = worldObjects(scene).filter((o) => o.mesh);
|
|
111
|
+
|
|
81
112
|
// Wireframes only: nothing to sort
|
|
82
|
-
if (!
|
|
83
|
-
for (const obj of
|
|
84
|
-
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);
|
|
85
116
|
}
|
|
117
|
+
for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
|
|
86
118
|
return;
|
|
87
119
|
}
|
|
88
120
|
|
|
@@ -96,18 +128,18 @@ export class CanvasRenderer {
|
|
|
96
128
|
const time = (performance.now() - this._start) / 1000;
|
|
97
129
|
const background = this._rgba(ctx, scene.background);
|
|
98
130
|
|
|
99
|
-
for (const obj of
|
|
131
|
+
for (const obj of drawn) {
|
|
132
|
+
const mats = materialsOf(obj);
|
|
100
133
|
// Split big faces (cached per mesh); extra vertices are midpoints of existing ones
|
|
101
|
-
const split =
|
|
134
|
+
const split = mats && this.maxFaceSize ? this._split(obj.mesh) : null;
|
|
102
135
|
const verts = split ? withMidpoints(obj.mesh.vertices, split.mids) : obj.mesh.vertices;
|
|
103
|
-
const world = verts.map((v) =>
|
|
136
|
+
const world = verts.map((v) => transformPoint(obj.world, v));
|
|
104
137
|
const cam = world.map((p) => worldToCamera(p, camera));
|
|
105
138
|
const color = obj.color ?? scene.color;
|
|
106
139
|
const lineWidth = obj.lineWidth ?? scene.lineWidth;
|
|
107
|
-
const mat = obj.material;
|
|
108
140
|
|
|
109
|
-
if (!
|
|
110
|
-
for (const [i, j] of edgeSegments(obj.mesh
|
|
141
|
+
if (!mats) {
|
|
142
|
+
for (const [i, j] of edgeSegments(wireEdges(obj.mesh))) {
|
|
111
143
|
const seg = clipNear(cam[i], cam[j], near);
|
|
112
144
|
if (!seg) continue;
|
|
113
145
|
items.push({
|
|
@@ -119,68 +151,90 @@ export class CanvasRenderer {
|
|
|
119
151
|
continue;
|
|
120
152
|
}
|
|
121
153
|
|
|
122
|
-
const fill = mat.fill ?? (mat.texture ? "#ffffff" : scene.color);
|
|
123
|
-
const shade = mat.shade !== false;
|
|
124
|
-
const outline = mat.wireframe ? color : null;
|
|
125
|
-
|
|
126
|
-
// Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
|
|
127
|
-
const crease = shade ? creaseOf(mat.smooth) : null;
|
|
128
|
-
const corners = crease ? cornerNormals(obj.mesh, crease) : null;
|
|
129
154
|
const sourceFaces = obj.mesh.faces ?? [];
|
|
130
155
|
const base = obj.mesh.vertices.length;
|
|
131
|
-
|
|
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
|
+
};
|
|
163
|
+
|
|
164
|
+
// Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
|
|
165
|
+
const normalAt = (corners, F, i) => {
|
|
132
166
|
if (i < base) return corners[F][sourceFaces[F].indexOf(i)];
|
|
133
167
|
const [a, b] = split.mids[i - base];
|
|
134
|
-
const na = normalAt(F, a), nb = normalAt(F, b);
|
|
168
|
+
const na = normalAt(corners, F, a), nb = normalAt(corners, F, b);
|
|
135
169
|
return normalize({ x: na.x + nb.x, y: na.y + nb.y, z: na.z + nb.z });
|
|
136
170
|
};
|
|
137
171
|
const lightAt = (n, back) => {
|
|
138
|
-
|
|
139
|
-
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);
|
|
140
173
|
const d = (back ? -1 : 1) * (r.x * light.x + r.y * light.y + r.z * light.z);
|
|
141
174
|
return ambient + (1 - ambient) * Math.max(0, d);
|
|
142
175
|
};
|
|
143
|
-
|
|
144
|
-
// Textures: each triangle is filled with the image, mapped by its corners' texture coordinates
|
|
145
|
-
const meshUVs = obj.mesh.uvs, faceUVs = obj.mesh.faceUVs;
|
|
146
|
-
let tex = null;
|
|
147
|
-
if (mat.texture && textureReady(mat.texture)) {
|
|
148
|
-
if (faceUVs && meshUVs) tex = mat.texture;
|
|
149
|
-
else if (!this._warned.has(obj.mesh)) {
|
|
150
|
-
this._warned.add(obj.mesh);
|
|
151
|
-
console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn");
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const uvAt = (F, i) => {
|
|
155
|
-
const t = faceUVs?.[F];
|
|
156
|
-
if (!t || !meshUVs) return null;
|
|
157
|
-
if (i < base) return meshUVs[t[sourceFaces[F].indexOf(i)]];
|
|
158
|
-
const [a, b] = split.mids[i - base];
|
|
159
|
-
const ta = uvAt(F, a), tb = uvAt(F, b);
|
|
160
|
-
return ta && tb ? { u: (ta.u + tb.u) / 2, v: (ta.v + tb.v) / 2 } : null;
|
|
161
|
-
};
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
181
|
+
const toWorldNormal = (n) => transformNormal(nm, n);
|
|
182
|
+
|
|
183
|
+
// Everything a material needs, worked out once per object and frame
|
|
184
|
+
const prepare = (mat) => {
|
|
185
|
+
const shade = mat.shade !== false;
|
|
186
|
+
const crease = shade ? creaseOf(mat.smooth) : null;
|
|
187
|
+
// Texture coordinates: the mesh's own, or made from its shape (mat.mapping)
|
|
188
|
+
const { uvs: meshUVs, faceUVs } = uvsFor(obj.mesh, mat.mapping);
|
|
189
|
+
const xf = uvTransform(mat);
|
|
190
|
+
const rawUV = (F, i) => {
|
|
191
|
+
const t = faceUVs?.[F];
|
|
192
|
+
if (!t || !meshUVs) return null;
|
|
193
|
+
if (i < base) return meshUVs[t[sourceFaces[F].indexOf(i)]];
|
|
194
|
+
const [a, b] = split.mids[i - base];
|
|
195
|
+
const ta = rawUV(F, a), tb = rawUV(F, b);
|
|
196
|
+
return ta && tb ? { u: (ta.u + tb.u) / 2, v: (ta.v + tb.v) / 2 } : null;
|
|
197
|
+
};
|
|
198
|
+
let tex = null;
|
|
199
|
+
if (mat.texture && textureReady(mat.texture)) {
|
|
200
|
+
if (faceUVs && meshUVs) tex = mat.texture;
|
|
201
|
+
else if (!this._warned.has(obj.mesh)) {
|
|
202
|
+
this._warned.add(obj.mesh);
|
|
203
|
+
console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn. Set material.mapping to make some.");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
mat, shade, tex,
|
|
208
|
+
fill: mat.fill ?? (mat.texture ? "#ffffff" : scene.color),
|
|
209
|
+
outline: mat.wireframe ? color : null,
|
|
210
|
+
corners: crease ? cornerNormals(obj.mesh, crease) : null,
|
|
211
|
+
effects: mat.effects?.length ? mat.effects : null,
|
|
212
|
+
uvAt: (F, i) => applyUVTransform(xf, rawUV(F, i)),
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
// Which material each face uses (mesh.faceMaterials), prepared when first needed
|
|
216
|
+
const looks = new Array(mats.length);
|
|
217
|
+
const faceMats = obj.mesh.faceMaterials;
|
|
218
|
+
const lookOf = (F) => {
|
|
219
|
+
let k = faceMats?.[F] ?? 0;
|
|
220
|
+
if (!(k >= 0 && k < mats.length && mats[k])) k = 0;
|
|
221
|
+
return (looks[k] ??= prepare(mats[k]));
|
|
222
|
+
};
|
|
172
223
|
|
|
173
224
|
const faces = split ? split.faces : sourceFaces;
|
|
174
225
|
for (let f = 0; f < faces.length; f++) {
|
|
175
226
|
const face = faces[f];
|
|
176
227
|
if (face.length < 3) continue;
|
|
228
|
+
const F = split ? split.origins[f] : f; // the mesh face this one comes from
|
|
229
|
+
const L = lookOf(F);
|
|
230
|
+
const { fill, shade, outline, corners } = L;
|
|
177
231
|
const pts = face.map((i) => cam[i]);
|
|
178
232
|
|
|
179
233
|
// Seen from behind? (camera sits at the origin of camera space)
|
|
180
234
|
const n = newell(pts);
|
|
181
235
|
const c = centroid(pts);
|
|
182
|
-
const back = n.x * c.x + n.y * c.y + n.z * c.z >= 0;
|
|
183
|
-
if (back && !mat.doubleSided) continue;
|
|
236
|
+
const back = (n.x * c.x + n.y * c.y + n.z * c.z >= 0) !== (flip < 0);
|
|
237
|
+
if (back && !L.mat.doubleSided) continue;
|
|
184
238
|
|
|
185
239
|
const clipped = clipPolygon(pts, near);
|
|
186
240
|
if (clipped.length < 3) continue;
|
|
@@ -188,13 +242,13 @@ export class CanvasRenderer {
|
|
|
188
242
|
// Which edges are real outlines (split faces have extra inner edges); null = all
|
|
189
243
|
const edges = split && clipped === pts ? split.masks[f] : null;
|
|
190
244
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const ts = face.map((i) => uvAt(F, i));
|
|
245
|
+
// Textures: each triangle is filled with the image, mapped by its corners' texture coordinates
|
|
246
|
+
if (L.tex && clipped === pts) {
|
|
247
|
+
const ts = face.map((i) => L.uvAt(F, i));
|
|
194
248
|
if (ts.every(Boolean)) {
|
|
195
249
|
let lit;
|
|
196
250
|
if (!shade) lit = face.map(() => 1);
|
|
197
|
-
else if (corners) lit = face.map((i) => lightAt(normalAt(F, i), back));
|
|
251
|
+
else if (corners) lit = face.map((i) => lightAt(normalAt(corners, F, i), back));
|
|
198
252
|
else lit = face.map(() => flatLight(face, back));
|
|
199
253
|
const screen = pts.map(toScreen);
|
|
200
254
|
const n = face.length;
|
|
@@ -203,7 +257,7 @@ export class CanvasRenderer {
|
|
|
203
257
|
items.push({
|
|
204
258
|
depth: c.z,
|
|
205
259
|
poly: [screen[0], screen[k], screen[k + 1]],
|
|
206
|
-
texture: { src: tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!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 },
|
|
207
261
|
lit: [lit[0], lit[k], lit[k + 1]],
|
|
208
262
|
outline, lineWidth,
|
|
209
263
|
edges: n === 3 ? edges : fan,
|
|
@@ -213,16 +267,16 @@ export class CanvasRenderer {
|
|
|
213
267
|
}
|
|
214
268
|
}
|
|
215
269
|
|
|
216
|
-
|
|
270
|
+
// Effects run once per face, at its center (see trid/render/effects.js)
|
|
271
|
+
if (L.effects) {
|
|
217
272
|
// Normal: the face's own, or the average of its corners when smooth
|
|
218
273
|
let nw;
|
|
219
274
|
if (corners) {
|
|
220
|
-
const
|
|
221
|
-
const sum = face.map((i) => toWorldNormal(normalAt(F, i)))
|
|
275
|
+
const sum = face.map((i) => toWorldNormal(normalAt(corners, F, i)))
|
|
222
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 });
|
|
223
277
|
nw = normalize(sum);
|
|
224
278
|
} else {
|
|
225
|
-
nw =
|
|
279
|
+
nw = flatNormal(face);
|
|
226
280
|
}
|
|
227
281
|
if (back) nw = { x: -nw.x, y: -nw.y, z: -nw.z };
|
|
228
282
|
|
|
@@ -230,16 +284,16 @@ export class CanvasRenderer {
|
|
|
230
284
|
const toCam = { x: camPos.x - position.x, y: camPos.y - position.y, z: camPos.z - position.z };
|
|
231
285
|
const diffuse = Math.max(0, nw.x * light.x + nw.y * light.y + nw.z * light.z);
|
|
232
286
|
const lit = shade ? ambient + (1 - ambient) * diffuse : 1;
|
|
233
|
-
const
|
|
287
|
+
const rgba = this._rgba(ctx, fill);
|
|
234
288
|
const s = {
|
|
235
|
-
base, normal: nw, position, local: centroid(face.map((i) => verts[i])),
|
|
289
|
+
base: rgba, normal: nw, position, local: centroid(face.map((i) => verts[i])),
|
|
236
290
|
view: normalize(toCam), lightDir: light, diffuse, light: lit, ambient,
|
|
237
|
-
uv: centerUV(face.map((i) => uvAt(
|
|
291
|
+
uv: centerUV(face.map((i) => L.uvAt(F, i))),
|
|
238
292
|
depth: Math.hypot(toCam.x, toCam.y, toCam.z), time, background,
|
|
239
293
|
rgba: (v) => (typeof v === "string" ? this._rgba(ctx, v) : Array.isArray(v) ? v : [v.x, v.y, v.z, 1]),
|
|
240
294
|
};
|
|
241
|
-
let col = [
|
|
242
|
-
for (const e of effects) if (e.canvas) col = e.canvas(col, s, e.uniforms ?? {});
|
|
295
|
+
let col = [rgba[0] * lit, rgba[1] * lit, rgba[2] * lit, rgba[3]];
|
|
296
|
+
for (const e of L.effects) if (e.canvas) col = e.canvas(col, s, e.uniforms ?? {});
|
|
243
297
|
const k = col.map((x) => Math.min(1, Math.max(0, x)));
|
|
244
298
|
items.push({
|
|
245
299
|
depth: c.z,
|
|
@@ -251,8 +305,7 @@ export class CanvasRenderer {
|
|
|
251
305
|
}
|
|
252
306
|
|
|
253
307
|
if (corners) {
|
|
254
|
-
const
|
|
255
|
-
const lit = face.map((i) => lightAt(normalAt(F, i), back));
|
|
308
|
+
const lit = face.map((i) => lightAt(normalAt(corners, F, i), back));
|
|
256
309
|
if (clipped === pts) {
|
|
257
310
|
// One gradient per triangle: brightness changes linearly across a triangle
|
|
258
311
|
const screen = pts.map(toScreen);
|
|
@@ -276,14 +329,7 @@ export class CanvasRenderer {
|
|
|
276
329
|
continue;
|
|
277
330
|
}
|
|
278
331
|
|
|
279
|
-
|
|
280
|
-
if (shade) {
|
|
281
|
-
const nw = normalize(newell(face.map((i) => world[i])));
|
|
282
|
-
const s = back ? -1 : 1;
|
|
283
|
-
const d = s * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
|
|
284
|
-
brightness = ambient + (1 - ambient) * Math.max(0, d);
|
|
285
|
-
}
|
|
286
|
-
|
|
332
|
+
const brightness = shade ? flatLight(face, back) : 1;
|
|
287
333
|
items.push({
|
|
288
334
|
depth: c.z,
|
|
289
335
|
poly: clipped.map(toScreen),
|
|
@@ -347,7 +393,7 @@ export class CanvasRenderer {
|
|
|
347
393
|
}
|
|
348
394
|
|
|
349
395
|
// Meshes marked as changed have been redrawn from their new data
|
|
350
|
-
for (const obj of
|
|
396
|
+
for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
|
|
351
397
|
}
|
|
352
398
|
|
|
353
399
|
/**
|
|
@@ -460,7 +506,7 @@ export class CanvasRenderer {
|
|
|
460
506
|
* triangle's corners, tinted by the fill color, then darkened by the light.
|
|
461
507
|
*/
|
|
462
508
|
_drawTextured(ctx, it) {
|
|
463
|
-
const { src, uvs, pixelated, tint } = it.texture;
|
|
509
|
+
const { src, uvs, pixelated, live, tint } = it.texture;
|
|
464
510
|
const { w: W, h: H } = textureSize(src);
|
|
465
511
|
const [p0, p1, p2] = it.poly;
|
|
466
512
|
|
|
@@ -486,7 +532,7 @@ export class CanvasRenderer {
|
|
|
486
532
|
const dx1 = p1.x - p0.x, dy1 = p1.y - p0.y, dx2 = p2.x - p0.x, dy2 = p2.y - p0.y;
|
|
487
533
|
const a = (dx1 * dv2 - dx2 * dv1) / det, b = (dy1 * dv2 - dy2 * dv1) / det;
|
|
488
534
|
const c = (dx2 * du1 - dx1 * du2) / det, d = (dy2 * du1 - dy1 * du2) / det;
|
|
489
|
-
const pattern = this._pattern(ctx, src);
|
|
535
|
+
const pattern = this._pattern(ctx, src, live);
|
|
490
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]]));
|
|
491
537
|
ctx.imageSmoothingEnabled = !pixelated;
|
|
492
538
|
ctx.fillStyle = pattern;
|
|
@@ -520,11 +566,15 @@ export class CanvasRenderer {
|
|
|
520
566
|
ctx.fill();
|
|
521
567
|
}
|
|
522
568
|
|
|
523
|
-
/**
|
|
524
|
-
|
|
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) {
|
|
525
574
|
let p = this._patterns.get(src);
|
|
526
|
-
|
|
527
|
-
|
|
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 };
|
|
528
578
|
this._patterns.set(src, p);
|
|
529
579
|
}
|
|
530
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.
|
|
@@ -59,11 +60,11 @@ export function line(ctx, p1, p2, color, width = 4) {
|
|
|
59
60
|
* - An edge with 3+ indices is a closed loop: the last index
|
|
60
61
|
* connects back to the first
|
|
61
62
|
*
|
|
62
|
-
* @param {number[][]} edges - Edge definitions using vertex indices
|
|
63
|
+
* @param {number[][]} [edges] - Edge definitions using vertex indices (none: no segments)
|
|
63
64
|
* @returns {Generator<[number, number]>} Index pairs to connect
|
|
64
65
|
*/
|
|
65
66
|
export function* edgeSegments(edges) {
|
|
66
|
-
for (const e of edges) {
|
|
67
|
+
for (const e of edges ?? []) {
|
|
67
68
|
const closed = e.length > 2;
|
|
68
69
|
const count = closed ? e.length : e.length - 1;
|
|
69
70
|
for (let i = 0; i < count; i++) {
|
|
@@ -72,6 +73,17 @@ export function* edgeSegments(edges) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The lines to draw for a mesh shown as a wireframe: its `edges`, or the
|
|
78
|
+
* outline of each face when it has no `edges` (a face-only mesh).
|
|
79
|
+
*
|
|
80
|
+
* @param {{ edges?: number[][], faces?: number[][] }} mesh
|
|
81
|
+
* @returns {number[][]} Edge definitions (faces are closed loops)
|
|
82
|
+
*/
|
|
83
|
+
export function wireEdges(mesh) {
|
|
84
|
+
return mesh.edges ?? mesh.faces ?? [];
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
/**
|
|
76
88
|
* Draws a wireframe object by connecting projected vertices
|
|
77
89
|
* using index-based edge definitions.
|
|
@@ -115,7 +127,7 @@ export function drawWireframe(ctx, points2, edges, color, width = 4) {
|
|
|
115
127
|
*
|
|
116
128
|
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context
|
|
117
129
|
* @param {{
|
|
118
|
-
* mesh:{ vertices:{x:number,y:number,z:number}[], edges
|
|
130
|
+
* mesh:{ vertices:{x:number,y:number,z:number}[], edges?:number[][], faces?:number[][] },
|
|
119
131
|
* pos:{x:number,y:number,z:number},
|
|
120
132
|
* rot:{x:number,y:number,z:number},
|
|
121
133
|
* scale:{x:number,y:number,z:number}
|
|
@@ -125,16 +137,19 @@ export function drawWireframe(ctx, points2, edges, color, width = 4) {
|
|
|
125
137
|
* @param {number} h - Canvas height
|
|
126
138
|
* @param {string} color - Wireframe color
|
|
127
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
|
|
128
142
|
*/
|
|
129
|
-
export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3) {
|
|
143
|
+
export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3, world) {
|
|
130
144
|
const near = camera.near ?? DEFAULT_NEAR;
|
|
131
|
-
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));
|
|
132
147
|
|
|
133
148
|
ctx.lineWidth = lineWidth;
|
|
134
149
|
ctx.strokeStyle = color;
|
|
135
150
|
|
|
136
151
|
ctx.beginPath();
|
|
137
|
-
for (const [i, j] of edgeSegments(inst.mesh
|
|
152
|
+
for (const [i, j] of edgeSegments(wireEdges(inst.mesh))) {
|
|
138
153
|
const seg = clipNear(ptsCam[i], ptsCam[j], near);
|
|
139
154
|
if (!seg) continue;
|
|
140
155
|
const a = cameraToScreen(seg[0], camera, w, h);
|