trid3d 0.6.0 → 0.7.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 +16 -8
- package/package.json +1 -1
- package/trid/index.d.ts +107 -0
- package/trid/index.js +7 -3
- package/trid/render/canvasRenderer.js +267 -4
- package/trid/render/lights.js +14 -1
- package/trid/render/post.js +154 -0
- package/trid/render/shadows.js +184 -0
- package/trid/render/webglRenderer.js +347 -16
- package/trid/tools/index.js +1 -0
- package/trid/tools/selectionOutline.js +244 -0
- package/trid/tools/transformGizmo.js +9 -1
package/README.md
CHANGED
|
@@ -20,15 +20,15 @@ Trid gives you a scene, shapes you can model in code, lights, effects, model loa
|
|
|
20
20
|
- **Modeling** — smooth curves through a few points (`spline`), shapes swept along a 3D path (`sweep`: tails, horns, pipes, rails), surfaces through cross-sections (`loft`: bodies, bottles, hulls, heads), subdivision surfaces with creases (`subdivideMesh`: box modeling), shapes that melt together (`blobMesh`: creatures, clay; or any distance field with `sdfMesh`), rounded or cut edges on any mesh (`bevelMesh`), taper, twist, bend and bulge deformers, mesh cleanup and checks (weld, clean, validate, fix winding), and rays, inside tests and distances against meshes and scenes (`meshQuery`, `raycastScene`).
|
|
21
21
|
- **Mesh helpers** — clone, transform, displace, merge and measure meshes; seeded random numbers and smooth noise (`fbm2`, `fbm3`, tileable) for rocks, terrain and procedural textures.
|
|
22
22
|
- **Filled faces** — give an object a `material` to fill and shade its faces, flat or smooth.
|
|
23
|
-
- **Lights** — ambient, directional and point lights, with colors and intensities, as many as you need (up to 8 at once besides ambient).
|
|
23
|
+
- **Lights and shadows** — ambient, directional and point lights, with colors and intensities, as many as you need (up to 8 at once besides ambient). A directional light can cast soft shadows (a shadow map with WebGL, ground shadows on the canvas).
|
|
24
24
|
- **Textures** — images, canvases or videos on faces, with texture coordinates from built-in shapes and OBJ files, or made from the shape (planar, box, sphere and cylinder mapping). Repeat, offset and rotate them, or give each face its own material. Or paint one in code: wood, marble, bricks and more, all seamless.
|
|
25
25
|
- **Effects** — stack shader effects like toon, rim and edge glow, gradients and fog on a material, or write your own in GLSL.
|
|
26
|
-
- **Post effects** — bloom, vignette, color grading
|
|
26
|
+
- **Post effects** — bloom, vignette, color grading, film grain and cartoon outlines over the whole picture, or your own full-screen shaders (several passes if needed, reading the scene's depth and normals if wanted).
|
|
27
27
|
- **Two renderers** — the canvas 2D context by default, or `WebGLRenderer` for exact depth and big models, with the same scene code.
|
|
28
28
|
- **OBJ files** — load models from a URL (with their `.mtl` colors and textures), or let users drop their own `.obj` files on the canvas.
|
|
29
29
|
- **Export** — `scene.screenshot()` and `scene.record()` save images and videos of the canvas; `exportOBJ` / `exportModel` write meshes, objects or whole scenes as OBJ files with their MTL materials and textures.
|
|
30
30
|
- **Camera** — position, yaw, pitch and field of view (50° by default), with near-plane clipping.
|
|
31
|
-
- **Tools** — orbit and fly cameras, object dragging, hover highlight, a move / rotate / scale gizmo (with a pivot of your choice), a view axes gizmo, a floor grid, an FPS meter and an OBJ uploader, one line each. Exact picking by triangles, with the hit point and texture coordinates under the mouse. Writing your own is a plain object with a few methods.
|
|
31
|
+
- **Tools** — orbit and fly cameras, object dragging, hover highlight, a selection outline, a move / rotate / scale gizmo (with a pivot of your choice), a view axes gizmo, a floor grid, an FPS meter and an OBJ uploader, one line each. Exact picking by triangles, with the hit point and texture coordinates under the mouse. Writing your own is a plain object with a few methods.
|
|
32
32
|
- **Pure math** — every transform and projection step is exported, plus 4×4 matrices and rotations, so you can use the pipeline piece by piece.
|
|
33
33
|
|
|
34
34
|
## Install
|
|
@@ -41,7 +41,7 @@ No bundler? Use the CDN with an import map, or copy the `trid/` folder next to y
|
|
|
41
41
|
|
|
42
42
|
```html
|
|
43
43
|
<script type="importmap">
|
|
44
|
-
{ "imports": { "trid3d": "https://cdn.jsdelivr.net/npm/trid3d@0.
|
|
44
|
+
{ "imports": { "trid3d": "https://cdn.jsdelivr.net/npm/trid3d@0.7/trid/index.js" } }
|
|
45
45
|
</script>
|
|
46
46
|
```
|
|
47
47
|
|
|
@@ -122,10 +122,17 @@ See [`docs/README.md`](https://github.com/ZachR622/Trid/blob/master/docs/README.
|
|
|
122
122
|
npm test
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
Uses Node's built-in test runner (Node 21+), so there's nothing to install. The tests cover shapes (lathe poles included), mesh helpers, curves, sweeps, lofts, subdivision, blobs, bevels, deformers, mesh cleanup, mesh and scene queries, noise, textures, OBJ / MTL, picking, tools, groups and matrices, lights, post effects, grids, the field of view, see-through backgrounds, and the WebGL renderer's GPU memory on a fake WebGL context.
|
|
125
|
+
Uses Node's built-in test runner (Node 21+), so there's nothing to install. The tests cover shapes (lathe poles included), mesh helpers, curves, sweeps, lofts, subdivision, blobs, bevels, deformers, mesh cleanup, mesh and scene queries, noise, textures, OBJ / MTL, picking, tools, groups and matrices, lights, shadows, post effects, grids, the field of view, see-through backgrounds, and the WebGL renderer's GPU memory on a fake WebGL context.
|
|
126
126
|
|
|
127
127
|
## Upgrading
|
|
128
128
|
|
|
129
|
+
### From 0.6 to 0.7
|
|
130
|
+
|
|
131
|
+
Nothing changes for existing scenes: shadows, outlines and the selection outline are all off until asked for. Only names Trid now uses itself could clash with yours:
|
|
132
|
+
|
|
133
|
+
- In **post effect GLSL**, `Frame` has two more fields (`f.near`, `f.far`), and the uniforms `uNear`, `uFar`, `uGeometry`, `uGeoNear` and `uGeoRange` are taken. A post effect declaring its own uniform with one of those names needs to rename it.
|
|
134
|
+
- In **material effect GLSL**, the functions `tridShadow` and `tridShadowDepth` and the uniforms starting with `uShadow` (plus `uReceive`) are taken. Effect uniforms are renamed by Trid, so only helper functions with those names would clash.
|
|
135
|
+
|
|
129
136
|
### From 0.5 to 0.6
|
|
130
137
|
|
|
131
138
|
- **The cone's tip is a single point** (a pole), so the cone is closed: `isClosed` and `validateMesh` no longer report a hole there. It has fewer vertices than before (its tip ring of 32 is now 1), and its side faces are triangles. Any `lathe` profile point with `r: 0` works the same way.
|
|
@@ -150,7 +157,8 @@ Uses Node's built-in test runner (Node 21+), so there's nothing to install. The
|
|
|
150
157
|
|
|
151
158
|
Roughly in order:
|
|
152
159
|
|
|
153
|
-
- **More
|
|
154
|
-
- **
|
|
155
|
-
- **More post effects** — outlines (from depth), antialiasing (FXAA)
|
|
160
|
+
- **More shadows** — point lights casting shadows, and several lights at once
|
|
161
|
+
- **Combining shapes** — union, subtract and intersect meshes (CSG)
|
|
156
162
|
- **More tests** — the canvas renderer's drawing and the remaining math functions
|
|
163
|
+
- **More import and export formats** — STL; basic glTF later
|
|
164
|
+
- **More post effects** — antialiasing (FXAA)
|
package/package.json
CHANGED
package/trid/index.d.ts
CHANGED
|
@@ -164,6 +164,10 @@ export interface SceneObjectInput {
|
|
|
164
164
|
hoverable?: boolean;
|
|
165
165
|
/** Set to false so `pick` / `pickHit` never find this object or its children (helpers like a grid) */
|
|
166
166
|
pickable?: boolean;
|
|
167
|
+
/** Set to false so this object (and its children) casts no shadow */
|
|
168
|
+
castShadow?: boolean;
|
|
169
|
+
/** Set to false so no shadow falls on this object (and its children) */
|
|
170
|
+
receiveShadow?: boolean;
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
/**
|
|
@@ -459,8 +463,35 @@ export interface DirectionalLight {
|
|
|
459
463
|
color?: string;
|
|
460
464
|
intensity?: number;
|
|
461
465
|
enabled?: boolean;
|
|
466
|
+
/** Cast shadows (the first directional light with it does): true, or settings */
|
|
467
|
+
shadows?: boolean | ShadowOptions;
|
|
462
468
|
}
|
|
463
469
|
|
|
470
|
+
/** Shadow settings of a directional light. */
|
|
471
|
+
export interface ShadowOptions {
|
|
472
|
+
/** Shadow map width in pixels (WebGL, default 2048). Bigger: sharper, slower */
|
|
473
|
+
size?: number;
|
|
474
|
+
/** Edge blur, in shadow map pixels (default 1.5; 0 = sharp) */
|
|
475
|
+
softness?: number;
|
|
476
|
+
/** Raise if lit surfaces get speckles; too high and shadows come loose (default 1) */
|
|
477
|
+
bias?: number;
|
|
478
|
+
/** Floor height for the canvas renderer's ground shadows (default: found) */
|
|
479
|
+
ground?: number;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Settings used for `shadows: true`. */
|
|
483
|
+
export const DEFAULT_SHADOWS: Readonly<Required<Omit<ShadowOptions, "ground">> & { ground: null }>;
|
|
484
|
+
/** A light's shadow settings, defaults filled in. */
|
|
485
|
+
export function shadowOptions(value: boolean | ShadowOptions): Required<Omit<ShadowOptions, "ground">> & { ground: number | null };
|
|
486
|
+
/** False when the object or one of its groups sets `key` to false. */
|
|
487
|
+
export function shadowFlag(obj: SceneObject, key: "castShadow" | "receiveShadow"): boolean;
|
|
488
|
+
/** The shadow map's box around casting objects (`matrix`: world → -1..1, column-major). */
|
|
489
|
+
export function shadowFrame(casters: SceneObject[], dir: Vec3): { matrix: Float32Array; radius: number } | null;
|
|
490
|
+
/** The floor height for ground shadows: where receivers have the most area of upward flat faces. */
|
|
491
|
+
export function findGround(receivers: SceneObject[]): number | null;
|
|
492
|
+
/** A world polygon's part above `ground`, flattened onto it along the light (`dir`: where it comes from). */
|
|
493
|
+
export function flattenOnGround(pts: Vec3[], dir: Vec3, ground: number): Vec3[];
|
|
494
|
+
|
|
464
495
|
/** Light from a point, like a bulb. */
|
|
465
496
|
export interface PointLight {
|
|
466
497
|
type: "point";
|
|
@@ -490,6 +521,8 @@ export interface SceneLights {
|
|
|
490
521
|
lights: { point: boolean; dir: Vec3 | null; pos: Vec3 | null; color: [number, number, number]; range: number }[];
|
|
491
522
|
/** Unit direction of the first directional light */
|
|
492
523
|
mainDir: Vec3;
|
|
524
|
+
/** The light casting shadows (`index` in `lights`) and its settings, or null */
|
|
525
|
+
shadow: ({ index: number; dir: Vec3 } & Required<Omit<ShadowOptions, "ground">> & { ground: number | null }) | null;
|
|
493
526
|
}
|
|
494
527
|
|
|
495
528
|
/** A scene's lights, colors parsed and multiplied by intensity, directions unit length. */
|
|
@@ -704,6 +737,10 @@ export interface PostFrame {
|
|
|
704
737
|
canvas: HTMLCanvasElement;
|
|
705
738
|
/** Turns a uniform value (CSS color, array) into RGBA 0..1 */
|
|
706
739
|
rgba(value: UniformValue): RGBA;
|
|
740
|
+
/** The camera's near distance */
|
|
741
|
+
near: number;
|
|
742
|
+
/** The camera's far distance */
|
|
743
|
+
far: number;
|
|
707
744
|
}
|
|
708
745
|
|
|
709
746
|
/** One pass of a post effect. */
|
|
@@ -727,8 +764,46 @@ export interface PostEffect<U extends Record<string, UniformValue> = Record<stri
|
|
|
727
764
|
uniforms?: U;
|
|
728
765
|
/** Set to false to turn it off without removing it */
|
|
729
766
|
enabled?: boolean;
|
|
767
|
+
/** Also read the scene's depth and normals (WebGL): GLSL gets `depthAt(uv)`, `normalAt(uv)` and `maskAt(uv)` */
|
|
768
|
+
geometry?: boolean;
|
|
769
|
+
/** With `geometry`: the objects `maskAt` is 1 on (children included). Default: all */
|
|
770
|
+
objects?: SceneObject[];
|
|
771
|
+
/** Canvas renderer: draw the meshes' silhouettes and creases as lines, using `uColor`, `uWidth` and `uCreaseAngle` */
|
|
772
|
+
contour?: boolean;
|
|
730
773
|
}
|
|
731
774
|
|
|
775
|
+
/** Cartoon-style lines around objects and along their sharp creases. */
|
|
776
|
+
export function outline(opts?: {
|
|
777
|
+
color?: string;
|
|
778
|
+
width?: number;
|
|
779
|
+
/** Degrees the surface must turn for an inner line (default 40); false = outer lines only */
|
|
780
|
+
creases?: number | false;
|
|
781
|
+
/** How readily a jump in distance makes a line (default 1; 0 = none) */
|
|
782
|
+
depth?: number;
|
|
783
|
+
/** Only outline these objects (and their children) */
|
|
784
|
+
objects?: SceneObject[];
|
|
785
|
+
}): PostEffect<{
|
|
786
|
+
uColor: string;
|
|
787
|
+
uWidth: number;
|
|
788
|
+
uCreaseAngle: number;
|
|
789
|
+
uDepth: number;
|
|
790
|
+
}>;
|
|
791
|
+
|
|
792
|
+
/** Crease angle `outline()` uses by default, in degrees (40). */
|
|
793
|
+
export const DEFAULT_OUTLINE_CREASE: number;
|
|
794
|
+
|
|
795
|
+
/** Whether a post effect reads the scene's depth and normals (`geometry: true`, with GLSL). */
|
|
796
|
+
export function wantsGeometry(effect: PostEffect): boolean;
|
|
797
|
+
|
|
798
|
+
/** The objects an effect's `objects` list covers, children included (null: all). */
|
|
799
|
+
export function maskedObjects(effect: PostEffect): Set<SceneObject> | null;
|
|
800
|
+
|
|
801
|
+
/** GLSL helpers `depthAt`, `normalAt` and `maskAt`, for effects with `geometry: true`. */
|
|
802
|
+
export const GEOMETRY_GLSL: string;
|
|
803
|
+
|
|
804
|
+
/** Each edge of a mesh's faces once, with the faces on its sides (`f2` = -1 on an open border). */
|
|
805
|
+
export function meshEdges(mesh: Mesh): { a: number; b: number; f1: number; f2: number }[];
|
|
806
|
+
|
|
732
807
|
/** Darkens the corners of the picture. */
|
|
733
808
|
export function vignette(opts?: { strength?: number; radius?: number; softness?: number; color?: string }): PostEffect<{
|
|
734
809
|
uStrength: number;
|
|
@@ -1323,6 +1398,36 @@ export interface TransformGizmoOptions {
|
|
|
1323
1398
|
onChange?: (op: TransformOp, targets: SceneObject[]) => void;
|
|
1324
1399
|
/** On release: the whole operation */
|
|
1325
1400
|
onDrop?: (op: TransformOp, targets: SceneObject[]) => void;
|
|
1401
|
+
/** Outline the targets, under the handles (default false). Options: see `SelectionOutline` */
|
|
1402
|
+
outline?: boolean | Omit<SelectionOutlineOptions, "targets">;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
export interface SelectionOutlineOptions {
|
|
1406
|
+
/** Objects to outline, or a function asked every frame (default none) */
|
|
1407
|
+
targets?: SceneObject[] | (() => SceneObject[]);
|
|
1408
|
+
/** Line color (default "#ffa64d") */
|
|
1409
|
+
color?: string;
|
|
1410
|
+
/** Line width in CSS pixels (default 2) */
|
|
1411
|
+
width?: number;
|
|
1412
|
+
/** Opacity of the outline where the objects are hidden behind others (default 0.35; 0 = not shown) */
|
|
1413
|
+
hidden?: number;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* A colored line around selected objects, like a 3D editor's selection. Parts hidden
|
|
1418
|
+
* behind other objects get a faint outline. Add it before tools that draw handles.
|
|
1419
|
+
*/
|
|
1420
|
+
export class SelectionOutline implements Tool {
|
|
1421
|
+
constructor(opts?: SelectionOutlineOptions);
|
|
1422
|
+
targets: SceneObject[] | (() => SceneObject[]);
|
|
1423
|
+
color: string;
|
|
1424
|
+
width: number;
|
|
1425
|
+
hidden: number;
|
|
1426
|
+
/** Hide the outline without removing the tool. */
|
|
1427
|
+
visible: boolean;
|
|
1428
|
+
/** The objects outlined this frame. */
|
|
1429
|
+
current(): SceneObject[];
|
|
1430
|
+
draw(ctx: CanvasRenderingContext2D, scene: Scene): void;
|
|
1326
1431
|
}
|
|
1327
1432
|
|
|
1328
1433
|
/**
|
|
@@ -1352,6 +1457,8 @@ export class TransformGizmo implements Tool {
|
|
|
1352
1457
|
visible: boolean;
|
|
1353
1458
|
/** True while a handle is being dragged. */
|
|
1354
1459
|
readonly dragging: boolean;
|
|
1460
|
+
/** The outline around the targets (`outline` option), or null. */
|
|
1461
|
+
outline: SelectionOutline | null;
|
|
1355
1462
|
/** The pivot in world space, or null when there are no targets. */
|
|
1356
1463
|
center(): Vec3 | null;
|
|
1357
1464
|
draw(ctx: CanvasRenderingContext2D, scene: Scene): void;
|
package/trid/index.js
CHANGED
|
@@ -19,11 +19,12 @@ export { gridMesh, gridHelper } from "./render/grid.js";
|
|
|
19
19
|
export { cloneMesh, transformMesh, displaceMesh, mergeMeshes, meshBounds } from "./render/meshUtils.js";
|
|
20
20
|
|
|
21
21
|
// Renderers
|
|
22
|
-
export { CanvasRenderer, DEFAULT_LIGHT, DEFAULT_AMBIENT, materialsOf } from "./render/canvasRenderer.js";
|
|
22
|
+
export { CanvasRenderer, DEFAULT_LIGHT, DEFAULT_AMBIENT, materialsOf, meshEdges } from "./render/canvasRenderer.js";
|
|
23
23
|
export { WebGLRenderer, DEFAULT_FAR } from "./render/webglRenderer.js";
|
|
24
24
|
|
|
25
25
|
// Lights
|
|
26
26
|
export { defaultLights, sceneLights, lightAt, lightLevels, rangeFade, MAX_LIGHTS } from "./render/lights.js";
|
|
27
|
+
export { shadowOptions, shadowFlag, shadowFrame, findGround, flattenOnGround, DEFAULT_SHADOWS } from "./render/shadows.js";
|
|
27
28
|
|
|
28
29
|
// Effects
|
|
29
30
|
export {
|
|
@@ -32,7 +33,10 @@ export {
|
|
|
32
33
|
} from "./render/effects.js";
|
|
33
34
|
|
|
34
35
|
// Post effects
|
|
35
|
-
export {
|
|
36
|
+
export {
|
|
37
|
+
vignette, bloom, grade, grain, outline, postPasses, activePost, runCanvasPost, wantsGeometry, maskedObjects,
|
|
38
|
+
FRAME_GLSL, GEOMETRY_GLSL, DEFAULT_OUTLINE_CREASE,
|
|
39
|
+
} from "./render/post.js";
|
|
36
40
|
|
|
37
41
|
// Procedural textures
|
|
38
42
|
export { makeTexture, texturePixels, colorBytes, TEXTURE_STYLES } from "./render/proceduralTextures.js";
|
|
@@ -56,7 +60,7 @@ export { screenshot, record, download } from "./export/capture.js";
|
|
|
56
60
|
// Tools
|
|
57
61
|
export {
|
|
58
62
|
OrbitCamera, FlyCamera, DragObject, HoverHighlight, FpsMeter, ObjUploader,
|
|
59
|
-
ViewGizmo, TransformGizmo,
|
|
63
|
+
ViewGizmo, TransformGizmo, SelectionOutline,
|
|
60
64
|
ToolManager, pick, pickHit, isPickable, screenBounds, worldPerPixel,
|
|
61
65
|
} from "./tools/index.js";
|
|
62
66
|
|
|
@@ -7,7 +7,8 @@ import { newell, cornerNormals, creaseOf } from "./normals.js";
|
|
|
7
7
|
import { textureReady, textureSize, textureIsLive, textureVersion } from "../loaders/texture.js";
|
|
8
8
|
import { uvsFor, uvTransform, applyUVTransform } from "./uvMapping.js";
|
|
9
9
|
import { DEFAULT_LIGHT, DEFAULT_AMBIENT, sceneLights, lightAt as lightOf, lightLevels } from "./lights.js";
|
|
10
|
-
import { runCanvasPost } from "./post.js";
|
|
10
|
+
import { runCanvasPost, activePost, maskedObjects } from "./post.js";
|
|
11
|
+
import { shadowFlag, findGround, flattenOnGround } from "./shadows.js";
|
|
11
12
|
|
|
12
13
|
export { newell };
|
|
13
14
|
|
|
@@ -126,6 +127,14 @@ export class CanvasRenderer {
|
|
|
126
127
|
const camPos = { x: camera.x ?? 0, y: camera.y ?? 0, z: camera.z ?? 0 };
|
|
127
128
|
const time = (performance.now() - this._start) / 1000;
|
|
128
129
|
const background = this._rgba(ctx, scene.background ?? "transparent");
|
|
130
|
+
// Post effects drawn as lines along the meshes (outline()): silhouettes and creases
|
|
131
|
+
const contours = activePost(scene.post).filter((e) => e.contour).map((e) => ({ e, mask: maskedObjects(e) }));
|
|
132
|
+
const outlines = [];
|
|
133
|
+
// Ground shadows (see trid/render/shadows.js): the floor's faces, then the shadows, then the rest
|
|
134
|
+
const sun = lights.shadow && lights.shadow.dir.y > 0.01 ? lights.shadow : null;
|
|
135
|
+
const ground = sun ? sun.ground ?? findGround(drawn.filter((o) => materialsOf(o) && shadowFlag(o, "receiveShadow"))) : null;
|
|
136
|
+
const shadowing = ground !== null && (camera.y ?? 0) > ground;
|
|
137
|
+
const shadowPolys = [];
|
|
129
138
|
|
|
130
139
|
for (const obj of drawn) {
|
|
131
140
|
const mats = materialsOf(obj);
|
|
@@ -137,6 +146,18 @@ export class CanvasRenderer {
|
|
|
137
146
|
const color = obj.color ?? scene.color;
|
|
138
147
|
const lineWidth = obj.lineWidth ?? scene.lineWidth;
|
|
139
148
|
|
|
149
|
+
// Its faces flattened on the floor along the light
|
|
150
|
+
if (shadowing && mats && shadowFlag(obj, "castShadow")) {
|
|
151
|
+
for (const face of obj.mesh.faces ?? []) {
|
|
152
|
+
if (face.length < 3) continue;
|
|
153
|
+
const flat = flattenOnGround(face.map((i) => world[i]), sun.dir, ground);
|
|
154
|
+
if (flat.length < 3) continue;
|
|
155
|
+
const clipped = clipPolygon(flat.map((p) => worldToCamera(p, camera)), near);
|
|
156
|
+
if (clipped.length >= 3) shadowPolys.push(clipped.map(toScreen));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const onGround = shadowing && mats && shadowFlag(obj, "receiveShadow");
|
|
160
|
+
|
|
140
161
|
if (!mats) {
|
|
141
162
|
for (const [i, j] of edgeSegments(wireEdges(obj.mesh))) {
|
|
142
163
|
const seg = clipNear(cam[i], cam[j], near);
|
|
@@ -213,11 +234,28 @@ export class CanvasRenderer {
|
|
|
213
234
|
return (looks[k] ??= prepare(mats[k]));
|
|
214
235
|
};
|
|
215
236
|
|
|
237
|
+
// Outline lines (outline()): placed in the drawing order once the faces are sorted
|
|
238
|
+
const lines = contours.length ? this._contours(obj, cam, flip, near, toScreen, contours) : null;
|
|
239
|
+
if (lines) outlines.push(lines);
|
|
240
|
+
let pendingF = -1, pendingStart = items.length, pendingGround = false;
|
|
241
|
+
// Marks the items of the last face with the face they come from, and whether it's floor
|
|
242
|
+
const attach = () => {
|
|
243
|
+
for (let k = pendingStart; k < items.length; k++) {
|
|
244
|
+
if (lines && pendingF >= 0) items[k].face = { lines, F: pendingF };
|
|
245
|
+
if (pendingGround) items[k].ground = true;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
216
249
|
const faces = split ? split.faces : sourceFaces;
|
|
217
250
|
for (let f = 0; f < faces.length; f++) {
|
|
218
251
|
const face = faces[f];
|
|
219
252
|
if (face.length < 3) continue;
|
|
220
253
|
const F = split ? split.origins[f] : f; // the mesh face this one comes from
|
|
254
|
+
attach();
|
|
255
|
+
pendingF = F;
|
|
256
|
+
pendingStart = items.length;
|
|
257
|
+
// Floor: lying on the ground level, facing up
|
|
258
|
+
pendingGround = onGround && face.every((i) => Math.abs(world[i].y - ground) < 1e-4) && newell(face.map((i) => world[i])).y * flip > 0;
|
|
221
259
|
const L = lookOf(F);
|
|
222
260
|
const { fill, shade, outline, corners } = L;
|
|
223
261
|
const pts = face.map((i) => cam[i]);
|
|
@@ -333,11 +371,31 @@ export class CanvasRenderer {
|
|
|
333
371
|
edges,
|
|
334
372
|
});
|
|
335
373
|
}
|
|
374
|
+
attach();
|
|
336
375
|
}
|
|
337
376
|
|
|
338
377
|
items.sort((a, b) => b.depth - a.depth);
|
|
339
378
|
|
|
340
|
-
|
|
379
|
+
// Each outline line is drawn once every face around its two ends is: those
|
|
380
|
+
// faces don't cover it then, but nearer ones of other objects still do
|
|
381
|
+
const linesAfter = new Map(); // item index → lines to draw after it
|
|
382
|
+
if (outlines.length) {
|
|
383
|
+
items.forEach((it, k) => {
|
|
384
|
+
if (it.face) it.face.lines.last[it.face.F] = k;
|
|
385
|
+
});
|
|
386
|
+
for (const L of outlines) {
|
|
387
|
+
for (const ln of L.list) {
|
|
388
|
+
let k = -1;
|
|
389
|
+
for (const F of ln.faces) k = Math.max(k, L.last[F] ?? -1);
|
|
390
|
+
if (k < 0) continue;
|
|
391
|
+
if (!linesAfter.has(k)) linesAfter.set(k, []);
|
|
392
|
+
linesAfter.get(k).push(ln);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const drawItem = (n) => {
|
|
398
|
+
const it = items[n];
|
|
341
399
|
ctx.beginPath();
|
|
342
400
|
if (it.line) {
|
|
343
401
|
ctx.moveTo(it.line[0].x, it.line[0].y);
|
|
@@ -345,7 +403,7 @@ export class CanvasRenderer {
|
|
|
345
403
|
ctx.lineWidth = it.lineWidth;
|
|
346
404
|
ctx.strokeStyle = it.color;
|
|
347
405
|
ctx.stroke();
|
|
348
|
-
|
|
406
|
+
return;
|
|
349
407
|
}
|
|
350
408
|
|
|
351
409
|
if (it.texture) {
|
|
@@ -385,6 +443,31 @@ export class CanvasRenderer {
|
|
|
385
443
|
ctx.strokeStyle = it.fill.css;
|
|
386
444
|
ctx.stroke();
|
|
387
445
|
}
|
|
446
|
+
|
|
447
|
+
const after = linesAfter.get(n);
|
|
448
|
+
if (after) {
|
|
449
|
+
ctx.save();
|
|
450
|
+
ctx.lineCap = "round";
|
|
451
|
+
for (const ln of after) {
|
|
452
|
+
ctx.beginPath();
|
|
453
|
+
ctx.moveTo(ln.p.x, ln.p.y);
|
|
454
|
+
ctx.lineTo(ln.q.x, ln.q.y);
|
|
455
|
+
ctx.lineWidth = ln.width;
|
|
456
|
+
ctx.strokeStyle = ln.color;
|
|
457
|
+
ctx.stroke();
|
|
458
|
+
}
|
|
459
|
+
ctx.restore();
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const floor = shadowing && shadowPolys.length ? items.filter((it) => it.ground) : [];
|
|
464
|
+
if (floor.length) {
|
|
465
|
+
// Everything else stands on the floor, so it's in front of it: floor, shadows, then the rest
|
|
466
|
+
for (let n = 0; n < items.length; n++) if (items[n].ground) drawItem(n);
|
|
467
|
+
this._groundShadow(ctx, w, h, shadowPolys, floor, lights, sun, ground);
|
|
468
|
+
for (let n = 0; n < items.length; n++) if (!items[n].ground) drawItem(n);
|
|
469
|
+
} else {
|
|
470
|
+
for (let n = 0; n < items.length; n++) drawItem(n);
|
|
388
471
|
}
|
|
389
472
|
|
|
390
473
|
// Meshes marked as changed have been redrawn from their new data
|
|
@@ -394,6 +477,118 @@ export class CanvasRenderer {
|
|
|
394
477
|
runCanvasPost(scene, scene.post, this._start);
|
|
395
478
|
}
|
|
396
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Darkens the floor where the shadows fall: the shadow shapes, blurred,
|
|
482
|
+
* kept only on the floor's faces, as dark as the light they take away.
|
|
483
|
+
*/
|
|
484
|
+
_groundShadow(ctx, w, h, polys, floor, lights, sun, ground) {
|
|
485
|
+
const [shade, mask] = [0, 1].map((i) => {
|
|
486
|
+
const c = (this._scratch ??= [])[i] ??= document.createElement("canvas");
|
|
487
|
+
if (c.width !== w || c.height !== h) {
|
|
488
|
+
c.width = w;
|
|
489
|
+
c.height = h;
|
|
490
|
+
}
|
|
491
|
+
const g = c.getContext("2d");
|
|
492
|
+
g.clearRect(0, 0, w, h);
|
|
493
|
+
return { c, g };
|
|
494
|
+
});
|
|
495
|
+
// The shadows, all turned the same way so overlaps don't cancel out
|
|
496
|
+
if (typeof shade.g.filter === "string" && sun.softness > 0) shade.g.filter = `blur(${sun.softness}px)`;
|
|
497
|
+
shade.g.beginPath();
|
|
498
|
+
for (const poly of polys) {
|
|
499
|
+
const p = signedArea(poly) < 0 ? poly.slice().reverse() : poly;
|
|
500
|
+
shade.g.moveTo(p[0].x, p[0].y);
|
|
501
|
+
for (let k = 1; k < p.length; k++) shade.g.lineTo(p[k].x, p[k].y);
|
|
502
|
+
shade.g.closePath();
|
|
503
|
+
}
|
|
504
|
+
shade.g.fillStyle = "#000000";
|
|
505
|
+
shade.g.fill();
|
|
506
|
+
shade.g.filter = "none";
|
|
507
|
+
// Only on the floor
|
|
508
|
+
mask.g.beginPath();
|
|
509
|
+
for (const it of floor) {
|
|
510
|
+
mask.g.moveTo(it.poly[0].x, it.poly[0].y);
|
|
511
|
+
for (let k = 1; k < it.poly.length; k++) mask.g.lineTo(it.poly[k].x, it.poly[k].y);
|
|
512
|
+
mask.g.closePath();
|
|
513
|
+
}
|
|
514
|
+
mask.g.fillStyle = mask.g.strokeStyle = "#ffffff";
|
|
515
|
+
mask.g.fill();
|
|
516
|
+
mask.g.lineWidth = 1;
|
|
517
|
+
mask.g.stroke();
|
|
518
|
+
shade.g.globalCompositeOperation = "destination-in";
|
|
519
|
+
shade.g.drawImage(mask.c, 0, 0);
|
|
520
|
+
shade.g.globalCompositeOperation = "source-over";
|
|
521
|
+
|
|
522
|
+
// As dark as the share of the floor's light this light gives
|
|
523
|
+
const up = { x: 0, y: 1, z: 0 }, at = { x: 0, y: ground, z: 0 };
|
|
524
|
+
const all = Math.max(...lightOf(lights, up, at));
|
|
525
|
+
const without = Math.max(...lightOf({ ...lights, lights: lights.lights.filter((_, i) => i !== sun.index) }, up, at));
|
|
526
|
+
const alpha = all > 0 ? Math.min(1, Math.max(0, 1 - without / all)) : 0;
|
|
527
|
+
if (alpha <= 0) return;
|
|
528
|
+
ctx.save();
|
|
529
|
+
ctx.globalAlpha = alpha;
|
|
530
|
+
ctx.drawImage(shade.c, 0, 0);
|
|
531
|
+
ctx.restore();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Outline lines of one object, for post effects with `contour` (outline()):
|
|
536
|
+
* its silhouette (edges between a face seen from the front and one seen
|
|
537
|
+
* from behind, or with no other face) and its creases (two front faces
|
|
538
|
+
* meeting at more than the effect's `uCreaseAngle`).
|
|
539
|
+
*
|
|
540
|
+
* @returns {{ list: {p:Object, q:Object, faces:number[], color:string, width:number}[], last: number[] }|null}
|
|
541
|
+
* `faces`: the mesh faces around the line's two ends. `last` is filled in after sorting
|
|
542
|
+
*/
|
|
543
|
+
_contours(obj, cam, flip, near, toScreen, contours) {
|
|
544
|
+
const used = contours.filter((c) => !c.mask || c.mask.has(obj));
|
|
545
|
+
if (!used.length) return null;
|
|
546
|
+
const faces = obj.mesh.faces ?? [];
|
|
547
|
+
const { edges, spot, facesAt } = meshTopology(obj.mesh);
|
|
548
|
+
// Each face's normal in camera space, and whether it faces the camera
|
|
549
|
+
const info = faces.map((face) => {
|
|
550
|
+
if (face.length < 3) return null;
|
|
551
|
+
const pts = face.map((i) => cam[i]);
|
|
552
|
+
const n = normalize(newell(pts));
|
|
553
|
+
const c = centroid(pts);
|
|
554
|
+
const back = (n.x * c.x + n.y * c.y + n.z * c.z >= 0) !== (flip < 0);
|
|
555
|
+
return { n, front: !back };
|
|
556
|
+
});
|
|
557
|
+
const mats = materialsOf(obj);
|
|
558
|
+
const faceMats = obj.mesh.faceMaterials;
|
|
559
|
+
const shown = (F) => {
|
|
560
|
+
if (!info[F]) return false;
|
|
561
|
+
if (info[F].front) return true;
|
|
562
|
+
return !!(mats[faceMats?.[F] ?? 0] ?? mats[0])?.doubleSided;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const list = [];
|
|
566
|
+
for (const c of used) {
|
|
567
|
+
const u = c.e.uniforms ?? {};
|
|
568
|
+
const angle = u.uCreaseAngle ?? 180;
|
|
569
|
+
const cos = angle >= 180 ? -2 : Math.cos((angle * Math.PI) / 180);
|
|
570
|
+
const rgba = typeof u.uColor === "string" ? null : u.uColor;
|
|
571
|
+
const color = rgba ? `rgba(${Math.round(rgba[0] * 255)}, ${Math.round(rgba[1] * 255)}, ${Math.round(rgba[2] * 255)}, ${rgba[3] ?? 1})` : u.uColor ?? "#1a1a1a";
|
|
572
|
+
const width = u.uWidth ?? 2;
|
|
573
|
+
for (const { a, b, f1, f2 } of edges) {
|
|
574
|
+
let line;
|
|
575
|
+
if (f2 < 0) line = shown(f1); // open border
|
|
576
|
+
else {
|
|
577
|
+
const s1 = shown(f1), s2 = shown(f2);
|
|
578
|
+
if (s1 && s2) {
|
|
579
|
+
const p = info[f1].n, q = info[f2].n;
|
|
580
|
+
line = info[f1].front !== info[f2].front || p.x * q.x + p.y * q.y + p.z * q.z < cos;
|
|
581
|
+
} else line = s1 || s2; // the other one faces away
|
|
582
|
+
}
|
|
583
|
+
if (!line) continue;
|
|
584
|
+
const seg = clipNear(cam[a], cam[b], near);
|
|
585
|
+
if (!seg) continue;
|
|
586
|
+
list.push({ p: toScreen(seg[0]), q: toScreen(seg[1]), faces: [...new Set([...facesAt[spot[a]], ...facesAt[spot[b]]])], color, width });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return list.length ? { list, last: [] } : null;
|
|
590
|
+
}
|
|
591
|
+
|
|
397
592
|
/**
|
|
398
593
|
* Splits a mesh's big faces into triangles no longer than
|
|
399
594
|
* maxFaceSize × the mesh's size. Cached until the mesh's faces change.
|
|
@@ -707,6 +902,74 @@ function withMidpoints(vertices, mids) {
|
|
|
707
902
|
return out;
|
|
708
903
|
}
|
|
709
904
|
|
|
905
|
+
function signedArea(pts) {
|
|
906
|
+
let a = 0;
|
|
907
|
+
for (let i = 0; i < pts.length; i++) {
|
|
908
|
+
const p = pts[i], q = pts[(i + 1) % pts.length];
|
|
909
|
+
a += p.x * q.y - q.x * p.y;
|
|
910
|
+
}
|
|
911
|
+
return a;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const edgeCache = new WeakMap();
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Each edge of a mesh's faces once, with the (up to two) faces on its sides.
|
|
918
|
+
* Vertices at the same spot count as one, so seams (where a shape repeats
|
|
919
|
+
* vertices for its texture coordinates) aren't taken for open borders.
|
|
920
|
+
* Cached until the mesh's arrays change or `mesh.dirty` is set.
|
|
921
|
+
*
|
|
922
|
+
* @returns {{a:number, b:number, f1:number, f2:number}[]} `f2` is -1 on an open border
|
|
923
|
+
*/
|
|
924
|
+
export function meshEdges(mesh) {
|
|
925
|
+
return meshTopology(mesh).edges;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* meshEdges, plus `spot` (vertex → id of its position) and `facesAt`
|
|
930
|
+
* (position id → the faces touching it).
|
|
931
|
+
*/
|
|
932
|
+
function meshTopology(mesh) {
|
|
933
|
+
const v = mesh.vertices, faces = mesh.faces ?? [];
|
|
934
|
+
const hit = edgeCache.get(mesh);
|
|
935
|
+
if (hit && !mesh.dirty && hit.v === v && hit.faces === faces && hit.counts === `${v.length}/${faces.length}`) return hit.topology;
|
|
936
|
+
|
|
937
|
+
// One id per spot
|
|
938
|
+
let size = 0;
|
|
939
|
+
for (const p of v) size = Math.max(size, Math.abs(p.x), Math.abs(p.y), Math.abs(p.z));
|
|
940
|
+
const step = (size || 1) * 1e-6;
|
|
941
|
+
const ids = new Map();
|
|
942
|
+
const spot = v.map((p) => {
|
|
943
|
+
const key = `${Math.round(p.x / step)},${Math.round(p.y / step)},${Math.round(p.z / step)}`;
|
|
944
|
+
let id = ids.get(key);
|
|
945
|
+
if (id === undefined) ids.set(key, (id = ids.size));
|
|
946
|
+
return id;
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
const byKey = new Map();
|
|
950
|
+
const edges = [];
|
|
951
|
+
const facesAt = Array.from({ length: ids.size }, () => []);
|
|
952
|
+
faces.forEach((f, F) => {
|
|
953
|
+
if (f.length < 3) return;
|
|
954
|
+
for (const i of f) if (facesAt[spot[i]].at(-1) !== F) facesAt[spot[i]].push(F);
|
|
955
|
+
for (let k = 0; k < f.length; k++) {
|
|
956
|
+
const a = f[k], b = f[(k + 1) % f.length];
|
|
957
|
+
const sa = spot[a], sb = spot[b];
|
|
958
|
+
if (sa === sb) continue;
|
|
959
|
+
const key = sa < sb ? sa * 4294967296 + sb : sb * 4294967296 + sa;
|
|
960
|
+
const e = byKey.get(key);
|
|
961
|
+
if (!e) {
|
|
962
|
+
const n = { a, b, f1: F, f2: -1 };
|
|
963
|
+
byKey.set(key, n);
|
|
964
|
+
edges.push(n);
|
|
965
|
+
} else if (e.f2 < 0 && e.f1 !== F) e.f2 = F;
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
const topology = { edges, spot, facesAt };
|
|
969
|
+
edgeCache.set(mesh, { v, faces, counts: `${v.length}/${faces.length}`, topology });
|
|
970
|
+
return topology;
|
|
971
|
+
}
|
|
972
|
+
|
|
710
973
|
function centroid(pts) {
|
|
711
974
|
let x = 0, y = 0, z = 0;
|
|
712
975
|
for (const p of pts) { x += p.x; y += p.y; z += p.z; }
|
|
@@ -719,7 +982,7 @@ function normalize(v) {
|
|
|
719
982
|
}
|
|
720
983
|
|
|
721
984
|
/** Cuts a camera-space polygon at z = near, keeping the part in front. */
|
|
722
|
-
function clipPolygon(pts, near) {
|
|
985
|
+
export function clipPolygon(pts, near) {
|
|
723
986
|
if (pts.every((p) => p.z >= near)) return pts;
|
|
724
987
|
const out = [];
|
|
725
988
|
for (let i = 0; i < pts.length; i++) {
|
package/trid/render/lights.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - `range`: a point light fades out smoothly and reaches nothing past it
|
|
13
13
|
* (default: no fading)
|
|
14
14
|
* - `enabled: false` turns a light off without removing it
|
|
15
|
+
* - `shadows: true` (directional lights): it casts shadows, see trid/render/shadows.js
|
|
15
16
|
*
|
|
16
17
|
* Lights add up: a face gets the ambient lights, plus each other light
|
|
17
18
|
* times how much the face turns toward it. Both renderers use at most
|
|
@@ -46,7 +47,10 @@ export function defaultLights(direction = DEFAULT_LIGHT, ambient = DEFAULT_AMBIE
|
|
|
46
47
|
];
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
import { shadowOptions } from "./shadows.js";
|
|
51
|
+
|
|
49
52
|
let warnedTooMany = false;
|
|
53
|
+
let warnedPointShadows = false;
|
|
50
54
|
|
|
51
55
|
/**
|
|
52
56
|
* A scene's lights, ready for shading: colors parsed and multiplied by
|
|
@@ -62,8 +66,10 @@ let warnedTooMany = false;
|
|
|
62
66
|
* ambientLevel: number,
|
|
63
67
|
* lights: { point:boolean, dir:{x,y,z}|null, pos:{x,y,z}|null, color:number[], range:number }[],
|
|
64
68
|
* mainDir: {x,y,z},
|
|
69
|
+
* shadow: { index:number, dir:{x,y,z}, size:number, softness:number, bias:number, ground:number|null } | null,
|
|
65
70
|
* }} `ambient` is the summed ambient color [r, g, b]; `ambientLevel` its brightest
|
|
66
|
-
* channel; `mainDir` the unit direction of the first directional light (for effects)
|
|
71
|
+
* channel; `mainDir` the unit direction of the first directional light (for effects);
|
|
72
|
+
* `shadow` the light casting shadows (`index` in `lights`) and its settings
|
|
67
73
|
*/
|
|
68
74
|
export function sceneLights(scene, rgba) {
|
|
69
75
|
const list = Array.isArray(scene.lights)
|
|
@@ -73,6 +79,7 @@ export function sceneLights(scene, rgba) {
|
|
|
73
79
|
const ambient = [0, 0, 0];
|
|
74
80
|
const lights = [];
|
|
75
81
|
let mainDir = null;
|
|
82
|
+
let shadow = null;
|
|
76
83
|
for (const l of list) {
|
|
77
84
|
if (!l || l.enabled === false) continue;
|
|
78
85
|
const k = l.intensity ?? 1;
|
|
@@ -90,11 +97,16 @@ export function sceneLights(scene, rgba) {
|
|
|
90
97
|
continue;
|
|
91
98
|
}
|
|
92
99
|
if (l.type === "point") {
|
|
100
|
+
if (l.shadows && !warnedPointShadows) {
|
|
101
|
+
warnedPointShadows = true;
|
|
102
|
+
console.warn("Trid: point lights don't cast shadows yet, only directional ones");
|
|
103
|
+
}
|
|
93
104
|
const p = l.position ?? { x: 0, y: 0, z: 0 };
|
|
94
105
|
lights.push({ point: true, dir: null, pos: { x: p.x, y: p.y, z: p.z }, color, range: l.range > 0 ? l.range : 0 });
|
|
95
106
|
} else {
|
|
96
107
|
const dir = unit(l.direction ?? DEFAULT_LIGHT);
|
|
97
108
|
mainDir ??= dir;
|
|
109
|
+
if (l.shadows && !shadow) shadow = { index: lights.length, dir, ...shadowOptions(l.shadows) };
|
|
98
110
|
lights.push({ point: false, dir, pos: null, color, range: 0 });
|
|
99
111
|
}
|
|
100
112
|
}
|
|
@@ -103,6 +115,7 @@ export function sceneLights(scene, rgba) {
|
|
|
103
115
|
ambientLevel: Math.max(...ambient),
|
|
104
116
|
lights,
|
|
105
117
|
mainDir: mainDir ?? unit(DEFAULT_LIGHT),
|
|
118
|
+
shadow,
|
|
106
119
|
};
|
|
107
120
|
}
|
|
108
121
|
|