micro-gl 0.1.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 +556 -0
- package/package.json +37 -0
- package/src/2d/cameras/Camera2d.js +46 -0
- package/src/2d/constants.js +3 -0
- package/src/2d/controls/DragControls2d.js +174 -0
- package/src/2d/controls/PanZoomControls.js +64 -0
- package/src/2d/core/GpuResources2d.js +163 -0
- package/src/2d/core/InstancedShape2d.js +74 -0
- package/src/2d/core/Object2d.js +107 -0
- package/src/2d/core/Pipelines2d.js +110 -0
- package/src/2d/core/Renderer2d.js +353 -0
- package/src/2d/core/Scene2d.js +13 -0
- package/src/2d/core/Shape2d.js +13 -0
- package/src/2d/core/Uniforms2d.js +13 -0
- package/src/2d/geometries/CircleGeometry.js +27 -0
- package/src/2d/geometries/Geometry2d.js +108 -0
- package/src/2d/geometries/RectGeometry.js +23 -0
- package/src/2d/materials/BasicMaterial2d.js +12 -0
- package/src/2d/materials/Material2d.js +78 -0
- package/src/2d/materials/SpriteMaterial2d.js +28 -0
- package/src/2d/shaders/fragments.js +23 -0
- package/src/2d/shaders/shared.js +28 -0
- package/src/2d/shaders/vertexLayout.js +74 -0
- package/src/2d/shaders/vertexStages.js +59 -0
- package/src/3d/cameras/Camera.js +66 -0
- package/src/3d/cameras/OrthographicCamera.js +35 -0
- package/src/3d/cameras/PerspectiveCamera.js +25 -0
- package/src/3d/constants.js +18 -0
- package/src/3d/controls/DragControls.js +168 -0
- package/src/3d/controls/OrbitControls.js +125 -0
- package/src/3d/core/DirectionalShadowMap.js +283 -0
- package/src/3d/core/GpuResources.js +174 -0
- package/src/3d/core/InstanceMatrix.js +55 -0
- package/src/3d/core/InstancedBounds.js +114 -0
- package/src/3d/core/InstancedMesh.js +124 -0
- package/src/3d/core/Mesh.js +26 -0
- package/src/3d/core/Object3d.js +101 -0
- package/src/3d/core/Pipelines.js +125 -0
- package/src/3d/core/RayIntersection.js +184 -0
- package/src/3d/core/Raycaster.js +246 -0
- package/src/3d/core/Renderer.js +492 -0
- package/src/3d/core/Scene.js +13 -0
- package/src/3d/core/ShadowPipelines.js +64 -0
- package/src/3d/core/Uniforms.js +132 -0
- package/src/3d/geometries/BoxGeometry.js +56 -0
- package/src/3d/geometries/Geometry.js +97 -0
- package/src/3d/geometries/PlaneGeometry.js +24 -0
- package/src/3d/geometries/SphereGeometry.js +46 -0
- package/src/3d/geometries/WireframeGeometry.js +39 -0
- package/src/3d/helpers/GridHelper.js +43 -0
- package/src/3d/lights/AmbientLight.js +18 -0
- package/src/3d/lights/DirectionalLight.js +26 -0
- package/src/3d/lights/DirectionalShadow.js +29 -0
- package/src/3d/lights/PointLight.js +23 -0
- package/src/3d/materials/BasicMaterial.js +11 -0
- package/src/3d/materials/LambertMaterial.js +13 -0
- package/src/3d/materials/Material.js +87 -0
- package/src/3d/materials/TextureMaterial.js +24 -0
- package/src/3d/shaders/fragments.js +34 -0
- package/src/3d/shaders/shadows.js +52 -0
- package/src/3d/shaders/shared.js +129 -0
- package/src/3d/shaders/vertexLayout.js +85 -0
- package/src/3d/shaders/vertexStages.js +70 -0
- package/src/core/PointerControls.js +258 -0
- package/src/core/Texture.js +87 -0
- package/src/core/createMaterialPipelineLayouts.js +114 -0
- package/src/core/deviceLease.js +67 -0
- package/src/core/generateMipmaps.js +115 -0
- package/src/core/indexedTriangles.js +53 -0
- package/src/core/initWebGpu.js +65 -0
- package/src/core/materialResources.js +18 -0
- package/src/core/objectGpuResources.js +68 -0
- package/src/core/pipelineConstants.js +68 -0
- package/src/core/rendererConfig.js +66 -0
- package/src/core/uploadTexture.js +57 -0
- package/src/index.js +68 -0
- package/src/math/Frustum.js +143 -0
- package/src/math/Mat3.js +165 -0
- package/src/math/Mat4.js +359 -0
- package/src/math/Vec2.js +72 -0
- package/src/math/Vec3.js +98 -0
- package/src/math/color.js +20 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SHADER_BIND_GROUP,
|
|
3
|
+
SHADER_BINDING,
|
|
4
|
+
SHADOW_BINDING,
|
|
5
|
+
} from '../../core/pipelineConstants.js';
|
|
6
|
+
import { VERTEX_ATTRIBUTE } from './vertexLayout.js';
|
|
7
|
+
import { MAX_POINT_LIGHTS } from '../constants.js';
|
|
8
|
+
|
|
9
|
+
export { MAX_POINT_LIGHTS };
|
|
10
|
+
|
|
11
|
+
/** Object-uniform layout shared by color and shadow-depth shader modules. */
|
|
12
|
+
export const OBJECT_UNIFORM_WGSL = /* wgsl */ `
|
|
13
|
+
struct ObjectUniforms {
|
|
14
|
+
model: mat4x4f,
|
|
15
|
+
normalMatrix: mat4x4f,
|
|
16
|
+
color: vec4f,
|
|
17
|
+
// x: whether this mesh receives the directional shadow.
|
|
18
|
+
shadowFlags: vec4f,
|
|
19
|
+
};
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
/** Directional-shadow uniform layout shared by both render passes. */
|
|
23
|
+
export const SHADOW_UNIFORM_WGSL = /* wgsl */ `
|
|
24
|
+
struct ShadowUniforms {
|
|
25
|
+
viewProjection: mat4x4f,
|
|
26
|
+
// x: enabled, y: depth bias, z: normal bias, w: shadow texel size.
|
|
27
|
+
params: vec4f,
|
|
28
|
+
};
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/** Uniform declarations and shading helpers shared by every 3D shader. */
|
|
32
|
+
export const SHARED_SHADER_CHUNKS = /* wgsl */ `
|
|
33
|
+
struct PointLightUniform {
|
|
34
|
+
position: vec3f, // world space
|
|
35
|
+
color: vec3f, // linear, premultiplied by intensity
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
struct FrameUniforms {
|
|
39
|
+
viewProjection: mat4x4f,
|
|
40
|
+
lightDirection: vec3f,
|
|
41
|
+
lightColor: vec3f,
|
|
42
|
+
ambientColor: vec3f,
|
|
43
|
+
// Packs into ambientColor's 16-byte slot; how many array entries are live.
|
|
44
|
+
pointLightCount: f32,
|
|
45
|
+
pointLights: array<PointLightUniform, ${MAX_POINT_LIGHTS}>,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
${OBJECT_UNIFORM_WGSL}
|
|
49
|
+
${SHADOW_UNIFORM_WGSL}
|
|
50
|
+
|
|
51
|
+
@group(${SHADER_BIND_GROUP.frame}) @binding(${SHADER_BINDING.uniforms})
|
|
52
|
+
var<uniform> uFrame: FrameUniforms;
|
|
53
|
+
@group(${SHADER_BIND_GROUP.object}) @binding(${SHADER_BINDING.uniforms})
|
|
54
|
+
var<uniform> uObject: ObjectUniforms;
|
|
55
|
+
@group(${SHADER_BIND_GROUP.shadow}) @binding(${SHADOW_BINDING.uniforms})
|
|
56
|
+
var<uniform> uShadow: ShadowUniforms;
|
|
57
|
+
@group(${SHADER_BIND_GROUP.shadow}) @binding(${SHADOW_BINDING.map})
|
|
58
|
+
var uShadowMap: texture_depth_2d;
|
|
59
|
+
@group(${SHADER_BIND_GROUP.shadow}) @binding(${SHADOW_BINDING.sampler})
|
|
60
|
+
var uShadowSampler: sampler_comparison;
|
|
61
|
+
|
|
62
|
+
struct VertexIn {
|
|
63
|
+
@location(${VERTEX_ATTRIBUTE.position}) position: vec3f,
|
|
64
|
+
@location(${VERTEX_ATTRIBUTE.normal}) normal: vec3f,
|
|
65
|
+
@location(${VERTEX_ATTRIBUTE.uv}) uv: vec2f,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Returns the fraction of the 3x3 comparison-filter kernel that can see the
|
|
69
|
+
// directional light. Coordinates outside the configured shadow camera stay
|
|
70
|
+
// fully lit rather than clamping to the map's edge.
|
|
71
|
+
fn directionalShadow(n: vec3f, worldPosition: vec3f) -> f32 {
|
|
72
|
+
if (uShadow.params.x < 0.5 || uObject.shadowFlags.x < 0.5) {
|
|
73
|
+
return 1.0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let biasedPosition = worldPosition + n * uShadow.params.z;
|
|
77
|
+
let clip = uShadow.viewProjection * vec4f(biasedPosition, 1.0);
|
|
78
|
+
if (clip.w <= 0.0) {
|
|
79
|
+
return 1.0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let ndc = clip.xyz / clip.w;
|
|
83
|
+
let uv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
|
84
|
+
if (
|
|
85
|
+
uv.x < 0.0 || uv.x > 1.0 ||
|
|
86
|
+
uv.y < 0.0 || uv.y > 1.0 ||
|
|
87
|
+
ndc.z < 0.0 || ndc.z > 1.0
|
|
88
|
+
) {
|
|
89
|
+
return 1.0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let referenceDepth = ndc.z - uShadow.params.y;
|
|
93
|
+
var visibility = 0.0;
|
|
94
|
+
for (var y = -1; y <= 1; y++) {
|
|
95
|
+
for (var x = -1; x <= 1; x++) {
|
|
96
|
+
let offset = vec2f(f32(x), f32(y)) * uShadow.params.w;
|
|
97
|
+
visibility += textureSampleCompareLevel(
|
|
98
|
+
uShadowMap,
|
|
99
|
+
uShadowSampler,
|
|
100
|
+
uv + offset,
|
|
101
|
+
referenceDepth,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return visibility / 9.0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Ambient + the directional light + the point lights on a diffuse
|
|
109
|
+
// surface, in linear space. Lit fragment shaders multiply the surface
|
|
110
|
+
// color by this.
|
|
111
|
+
fn diffuseLighting(n: vec3f, worldPosition: vec3f) -> vec3f {
|
|
112
|
+
var lighting = uFrame.ambientColor;
|
|
113
|
+
let toLight = normalize(-uFrame.lightDirection);
|
|
114
|
+
let shadow = directionalShadow(n, worldPosition);
|
|
115
|
+
lighting += max(dot(n, toLight), 0.0) * uFrame.lightColor * shadow;
|
|
116
|
+
let count = u32(uFrame.pointLightCount);
|
|
117
|
+
for (var i = 0u; i < count; i++) {
|
|
118
|
+
let offset = uFrame.pointLights[i].position - worldPosition;
|
|
119
|
+
let distanceSq = max(dot(offset, offset), 1e-6);
|
|
120
|
+
// 1 / (1 + d^2) falloff: intensity is the brightness right at the
|
|
121
|
+
// light, fading smoothly and never blowing up at zero distance.
|
|
122
|
+
let attenuation = 1.0 / (1.0 + distanceSq);
|
|
123
|
+
let direction = offset * inverseSqrt(distanceSq);
|
|
124
|
+
lighting += max(dot(n, direction), 0.0) * attenuation
|
|
125
|
+
* uFrame.pointLights[i].color;
|
|
126
|
+
}
|
|
127
|
+
return lighting;
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { VERTEX_STRIDE } from '../geometries/Geometry.js';
|
|
2
|
+
import { INSTANCE_SIZE } from '../constants.js';
|
|
3
|
+
import { INSTANCE_STEP_MODE } from '../../core/pipelineConstants.js';
|
|
4
|
+
|
|
5
|
+
const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
|
|
6
|
+
const VEC2_BYTES = 2 * FLOAT_BYTES;
|
|
7
|
+
const VEC3_BYTES = 3 * FLOAT_BYTES;
|
|
8
|
+
const VEC4_BYTES = 4 * FLOAT_BYTES;
|
|
9
|
+
const MAT4_BYTES = 4 * VEC4_BYTES;
|
|
10
|
+
|
|
11
|
+
/** Vertex input locations shared by WGSL and the GPU buffer descriptors. */
|
|
12
|
+
export const VERTEX_ATTRIBUTE = Object.freeze({
|
|
13
|
+
position: 0,
|
|
14
|
+
normal: 1,
|
|
15
|
+
uv: 2,
|
|
16
|
+
instanceMatrix0: 3,
|
|
17
|
+
instanceMatrix1: 4,
|
|
18
|
+
instanceMatrix2: 5,
|
|
19
|
+
instanceMatrix3: 6,
|
|
20
|
+
instanceColor: 7,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const GEOMETRY_VERTEX_BUFFER_LAYOUT = Object.freeze({
|
|
24
|
+
arrayStride: VERTEX_STRIDE,
|
|
25
|
+
attributes: Object.freeze([
|
|
26
|
+
Object.freeze({
|
|
27
|
+
shaderLocation: VERTEX_ATTRIBUTE.position,
|
|
28
|
+
offset: 0,
|
|
29
|
+
format: 'float32x3',
|
|
30
|
+
}),
|
|
31
|
+
Object.freeze({
|
|
32
|
+
shaderLocation: VERTEX_ATTRIBUTE.normal,
|
|
33
|
+
offset: VEC3_BYTES,
|
|
34
|
+
format: 'float32x3',
|
|
35
|
+
}),
|
|
36
|
+
Object.freeze({
|
|
37
|
+
shaderLocation: VERTEX_ATTRIBUTE.uv,
|
|
38
|
+
offset: 2 * VEC3_BYTES,
|
|
39
|
+
format: 'float32x2',
|
|
40
|
+
}),
|
|
41
|
+
]),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const INSTANCE_VERTEX_BUFFER_LAYOUT = Object.freeze({
|
|
45
|
+
arrayStride: INSTANCE_SIZE * FLOAT_BYTES,
|
|
46
|
+
stepMode: INSTANCE_STEP_MODE,
|
|
47
|
+
attributes: Object.freeze([
|
|
48
|
+
Object.freeze({
|
|
49
|
+
shaderLocation: VERTEX_ATTRIBUTE.instanceMatrix0,
|
|
50
|
+
offset: 0,
|
|
51
|
+
format: 'float32x4',
|
|
52
|
+
}),
|
|
53
|
+
Object.freeze({
|
|
54
|
+
shaderLocation: VERTEX_ATTRIBUTE.instanceMatrix1,
|
|
55
|
+
offset: VEC4_BYTES,
|
|
56
|
+
format: 'float32x4',
|
|
57
|
+
}),
|
|
58
|
+
Object.freeze({
|
|
59
|
+
shaderLocation: VERTEX_ATTRIBUTE.instanceMatrix2,
|
|
60
|
+
offset: 2 * VEC4_BYTES,
|
|
61
|
+
format: 'float32x4',
|
|
62
|
+
}),
|
|
63
|
+
Object.freeze({
|
|
64
|
+
shaderLocation: VERTEX_ATTRIBUTE.instanceMatrix3,
|
|
65
|
+
offset: 3 * VEC4_BYTES,
|
|
66
|
+
format: 'float32x4',
|
|
67
|
+
}),
|
|
68
|
+
Object.freeze({
|
|
69
|
+
shaderLocation: VERTEX_ATTRIBUTE.instanceColor,
|
|
70
|
+
offset: MAT4_BYTES,
|
|
71
|
+
format: 'float32x4',
|
|
72
|
+
}),
|
|
73
|
+
]),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const STANDARD_VERTEX_BUFFERS = Object.freeze([GEOMETRY_VERTEX_BUFFER_LAYOUT]);
|
|
77
|
+
const INSTANCED_VERTEX_BUFFERS = Object.freeze([
|
|
78
|
+
GEOMETRY_VERTEX_BUFFER_LAYOUT,
|
|
79
|
+
INSTANCE_VERTEX_BUFFER_LAYOUT,
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** GPU vertex buffer layouts for a regular or instanced mesh pipeline. */
|
|
83
|
+
export function vertexBufferLayouts(instanced) {
|
|
84
|
+
return instanced ? INSTANCED_VERTEX_BUFFERS : STANDARD_VERTEX_BUFFERS;
|
|
85
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SHARED_SHADER_CHUNKS } from './shared.js';
|
|
2
|
+
import { VERTEX_ATTRIBUTE } from './vertexLayout.js';
|
|
3
|
+
|
|
4
|
+
/** Shared chunks plus the regular mesh vertex stage. Append a fragment stage. */
|
|
5
|
+
export const MESH_SHADER_PREFIX =
|
|
6
|
+
SHARED_SHADER_CHUNKS +
|
|
7
|
+
/* wgsl */ `
|
|
8
|
+
struct VertexOut {
|
|
9
|
+
@builtin(position) position: vec4f,
|
|
10
|
+
@location(0) worldNormal: vec3f,
|
|
11
|
+
@location(1) uv: vec2f,
|
|
12
|
+
@location(2) worldPosition: vec3f,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
@vertex
|
|
16
|
+
fn vs(input: VertexIn) -> VertexOut {
|
|
17
|
+
var out: VertexOut;
|
|
18
|
+
let worldPosition = uObject.model * vec4f(input.position, 1.0);
|
|
19
|
+
out.position = uFrame.viewProjection * worldPosition;
|
|
20
|
+
out.worldNormal = (uObject.normalMatrix * vec4f(input.normal, 0.0)).xyz;
|
|
21
|
+
out.uv = input.uv;
|
|
22
|
+
out.worldPosition = worldPosition.xyz;
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fn objectColor(input: VertexOut) -> vec4f {
|
|
27
|
+
return uObject.color;
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/** Shared chunks plus the per-instance mesh vertex stage. Append a fragment stage. */
|
|
32
|
+
export const INSTANCED_MESH_SHADER_PREFIX =
|
|
33
|
+
SHARED_SHADER_CHUNKS +
|
|
34
|
+
/* wgsl */ `
|
|
35
|
+
struct InstanceIn {
|
|
36
|
+
@location(${VERTEX_ATTRIBUTE.instanceMatrix0}) im0: vec4f,
|
|
37
|
+
@location(${VERTEX_ATTRIBUTE.instanceMatrix1}) im1: vec4f,
|
|
38
|
+
@location(${VERTEX_ATTRIBUTE.instanceMatrix2}) im2: vec4f,
|
|
39
|
+
@location(${VERTEX_ATTRIBUTE.instanceMatrix3}) im3: vec4f,
|
|
40
|
+
@location(${VERTEX_ATTRIBUTE.instanceColor}) color: vec4f,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
struct VertexOut {
|
|
44
|
+
@builtin(position) position: vec4f,
|
|
45
|
+
@location(0) worldNormal: vec3f,
|
|
46
|
+
@location(1) uv: vec2f,
|
|
47
|
+
@location(2) color: vec4f,
|
|
48
|
+
@location(3) worldPosition: vec3f,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
@vertex
|
|
52
|
+
fn vs(input: VertexIn, instance: InstanceIn) -> VertexOut {
|
|
53
|
+
var out: VertexOut;
|
|
54
|
+
let instanceMatrix = mat4x4f(instance.im0, instance.im1, instance.im2, instance.im3);
|
|
55
|
+
let worldPosition = uObject.model * instanceMatrix * vec4f(input.position, 1.0);
|
|
56
|
+
out.position = uFrame.viewProjection * worldPosition;
|
|
57
|
+
// The instance matrix's upper 3x3 handles rotation + uniform scale
|
|
58
|
+
// (lighting normalizes); per-instance non-uniform scale skews normals.
|
|
59
|
+
let rotation = mat3x3f(instance.im0.xyz, instance.im1.xyz, instance.im2.xyz);
|
|
60
|
+
out.worldNormal = (uObject.normalMatrix * vec4f(rotation * input.normal, 0.0)).xyz;
|
|
61
|
+
out.uv = input.uv;
|
|
62
|
+
out.color = instance.color;
|
|
63
|
+
out.worldPosition = worldPosition.xyz;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fn objectColor(input: VertexOut) -> vec4f {
|
|
68
|
+
return input.color * uObject.color;
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
export const TOUCH_GESTURE = Object.freeze({
|
|
2
|
+
PAN: 'pan',
|
|
3
|
+
ROTATE: 'rotate',
|
|
4
|
+
});
|
|
5
|
+
|
|
6
|
+
const MOUSE_POINTER_TYPE = 'mouse';
|
|
7
|
+
const RIGHT_MOUSE_BUTTON = 2;
|
|
8
|
+
const LEFT_MOUSE_BUTTON = 0;
|
|
9
|
+
const TWO_TOUCHES = 2;
|
|
10
|
+
const NDC_SPAN = 2;
|
|
11
|
+
const CONTEXT_MENU_PAN_THRESHOLD = 3;
|
|
12
|
+
const DEFAULT_ROTATE_SPEED = 0.005;
|
|
13
|
+
const DEFAULT_ZOOM_SPEED = 0.001;
|
|
14
|
+
const touchActionClaims = new WeakMap();
|
|
15
|
+
|
|
16
|
+
function claimTouchAction(element) {
|
|
17
|
+
let claim = touchActionClaims.get(element);
|
|
18
|
+
if (!claim) {
|
|
19
|
+
claim = { owners: 0, previous: element.style.touchAction };
|
|
20
|
+
touchActionClaims.set(element, claim);
|
|
21
|
+
}
|
|
22
|
+
claim.owners++;
|
|
23
|
+
element.style.touchAction = 'none';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function releaseTouchAction(element) {
|
|
27
|
+
const claim = touchActionClaims.get(element);
|
|
28
|
+
if (!claim) return;
|
|
29
|
+
claim.owners--;
|
|
30
|
+
if (claim.owners === 0) {
|
|
31
|
+
element.style.touchAction = claim.previous;
|
|
32
|
+
touchActionClaims.delete(element);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The pointer / wheel plumbing shared by the camera controls
|
|
38
|
+
* (OrbitControls in 3D, PanZoomControls in 2D). Owns everything about
|
|
39
|
+
* reading the input device: button roles, pointer capture, keeping the
|
|
40
|
+
* browser context menu available after a plain right-click, turning
|
|
41
|
+
* wheel events into zoom factors with the cursor position in NDC, and
|
|
42
|
+
* the touch gestures.
|
|
43
|
+
*
|
|
44
|
+
* Mouse gestures: right-drag pans, Alt + left-drag rotates, scroll
|
|
45
|
+
* zooms toward the cursor. Touch gestures: a one-finger drag pans or
|
|
46
|
+
* rotates (see `singleTouchGesture`), a two-finger drag pans, and
|
|
47
|
+
* pinching zooms toward the midpoint between the fingers.
|
|
48
|
+
*
|
|
49
|
+
* Subclasses implement just the camera math:
|
|
50
|
+
* _pan(dx, dy) pan deltas, in pixels
|
|
51
|
+
* _rotate(rx, ry) rotation deltas, pre-scaled by rotateSpeed
|
|
52
|
+
* _zoom(factor, ndcX, ndcY) zoom factor plus the cursor in -1..1 NDC
|
|
53
|
+
*/
|
|
54
|
+
export class PointerControls {
|
|
55
|
+
constructor(domElement) {
|
|
56
|
+
this.domElement = domElement;
|
|
57
|
+
|
|
58
|
+
/** Set to false to ignore input, e.g. while dragging an object. */
|
|
59
|
+
this.enabled = true;
|
|
60
|
+
this.rotateSpeed = DEFAULT_ROTATE_SPEED;
|
|
61
|
+
this.zoomSpeed = DEFAULT_ZOOM_SPEED;
|
|
62
|
+
/** Set to false to disable the rotate gesture (pan and zoom still work). */
|
|
63
|
+
this.enableRotate = true;
|
|
64
|
+
/**
|
|
65
|
+
* What a one-finger touch drag does: 'pan' (default) or 'rotate'.
|
|
66
|
+
* Two-finger gestures always pan and pinch-zoom.
|
|
67
|
+
*/
|
|
68
|
+
this.singleTouchGesture = TOUCH_GESTURE.PAN;
|
|
69
|
+
|
|
70
|
+
this._dragging = false;
|
|
71
|
+
this._panning = false;
|
|
72
|
+
this._leftDrag = false;
|
|
73
|
+
this._panDistance = 0;
|
|
74
|
+
this._lastX = 0;
|
|
75
|
+
this._lastY = 0;
|
|
76
|
+
this._mousePointerId = null;
|
|
77
|
+
this._touches = new Map(); // pointerId -> {x, y} of active touch points
|
|
78
|
+
this._pinchDistance = 0;
|
|
79
|
+
this._disposed = false;
|
|
80
|
+
|
|
81
|
+
// Without this the browser claims touch drags for scrolling and
|
|
82
|
+
// pinches for page zoom before the pointer events reach us.
|
|
83
|
+
claimTouchAction(domElement);
|
|
84
|
+
|
|
85
|
+
this._onPointerDown = this._handlePointerDown.bind(this);
|
|
86
|
+
this._onPointerMove = this._handlePointerMove.bind(this);
|
|
87
|
+
this._onPointerUp = this._handlePointerUp.bind(this);
|
|
88
|
+
this._onContextMenu = this._handleContextMenu.bind(this);
|
|
89
|
+
this._onWheel = this._handleWheel.bind(this);
|
|
90
|
+
|
|
91
|
+
domElement.addEventListener('pointerdown', this._onPointerDown);
|
|
92
|
+
domElement.addEventListener('pointermove', this._onPointerMove);
|
|
93
|
+
domElement.addEventListener('pointerup', this._onPointerUp);
|
|
94
|
+
domElement.addEventListener('pointercancel', this._onPointerUp);
|
|
95
|
+
domElement.addEventListener('contextmenu', this._onContextMenu);
|
|
96
|
+
domElement.addEventListener('wheel', this._onWheel, { passive: false });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_handlePointerDown(event) {
|
|
100
|
+
if (!this.enabled) return;
|
|
101
|
+
if (event.pointerType !== MOUSE_POINTER_TYPE) {
|
|
102
|
+
this._touches.set(event.pointerId, {
|
|
103
|
+
x: event.clientX,
|
|
104
|
+
y: event.clientY,
|
|
105
|
+
});
|
|
106
|
+
if (this._touches.size === TWO_TOUCHES) {
|
|
107
|
+
const [first, second] = this._touches.values();
|
|
108
|
+
this._pinchDistance = Math.hypot(
|
|
109
|
+
first.x - second.x,
|
|
110
|
+
first.y - second.y,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
this.domElement.setPointerCapture(event.pointerId);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this._dragging = true;
|
|
118
|
+
this._mousePointerId = event.pointerId;
|
|
119
|
+
this._panning = event.button === RIGHT_MOUSE_BUTTON;
|
|
120
|
+
this._leftDrag = event.button === LEFT_MOUSE_BUTTON;
|
|
121
|
+
if (this._panning) this._panDistance = 0;
|
|
122
|
+
this._lastX = event.clientX;
|
|
123
|
+
this._lastY = event.clientY;
|
|
124
|
+
this.domElement.setPointerCapture(event.pointerId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_handlePointerMove(event) {
|
|
128
|
+
if (!this.enabled) return;
|
|
129
|
+
if (event.pointerType !== MOUSE_POINTER_TYPE) {
|
|
130
|
+
this._touchMove(event);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!this._dragging || event.pointerId !== this._mousePointerId) return;
|
|
134
|
+
|
|
135
|
+
const dx = event.clientX - this._lastX;
|
|
136
|
+
const dy = event.clientY - this._lastY;
|
|
137
|
+
this._lastX = event.clientX;
|
|
138
|
+
this._lastY = event.clientY;
|
|
139
|
+
if (this._panning) {
|
|
140
|
+
this._panDistance += Math.abs(dx) + Math.abs(dy);
|
|
141
|
+
this._pan(dx, dy);
|
|
142
|
+
} else if (this._leftDrag && event.altKey && this.enableRotate) {
|
|
143
|
+
// Alt is checked per move event, so pressing it mid-drag starts
|
|
144
|
+
// rotating and releasing it pauses.
|
|
145
|
+
this._rotate(dx * this.rotateSpeed, dy * this.rotateSpeed);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_handlePointerUp(event) {
|
|
150
|
+
if (event.pointerType !== MOUSE_POINTER_TYPE) {
|
|
151
|
+
this._touches.delete(event.pointerId);
|
|
152
|
+
// Dropping below two fingers re-arms the pinch for next time.
|
|
153
|
+
this._pinchDistance = 0;
|
|
154
|
+
} else {
|
|
155
|
+
if (event.pointerId !== this._mousePointerId) return;
|
|
156
|
+
this._dragging = false;
|
|
157
|
+
this._mousePointerId = null;
|
|
158
|
+
}
|
|
159
|
+
this._releasePointer(event.pointerId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_handleContextMenu(event) {
|
|
163
|
+
if (!this.enabled) return;
|
|
164
|
+
const wasPanGesture = this._panDistance > CONTEXT_MENU_PAN_THRESHOLD;
|
|
165
|
+
if (wasPanGesture || this._touches.size > 0) event.preventDefault();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_handleWheel(event) {
|
|
169
|
+
if (!this.enabled) return;
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
const factor = Math.exp(event.deltaY * this.zoomSpeed);
|
|
172
|
+
const ndc = this._toNdc(event.clientX, event.clientY);
|
|
173
|
+
this._zoom(factor, ndc.x, ndc.y);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_releasePointer(pointerId) {
|
|
177
|
+
if (this.domElement.hasPointerCapture(pointerId)) {
|
|
178
|
+
this.domElement.releasePointerCapture(pointerId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A client-space position in normalized device coordinates (-1..1). */
|
|
183
|
+
_toNdc(clientX, clientY) {
|
|
184
|
+
const rect = this.domElement.getBoundingClientRect();
|
|
185
|
+
return {
|
|
186
|
+
x: ((clientX - rect.left) / rect.width) * NDC_SPAN - 1,
|
|
187
|
+
y: 1 - ((clientY - rect.top) / rect.height) * NDC_SPAN,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_touchMove(e) {
|
|
192
|
+
const touch = this._touches.get(e.pointerId);
|
|
193
|
+
if (!touch) return;
|
|
194
|
+
|
|
195
|
+
if (this._touches.size === 1) {
|
|
196
|
+
const dx = e.clientX - touch.x;
|
|
197
|
+
const dy = e.clientY - touch.y;
|
|
198
|
+
touch.x = e.clientX;
|
|
199
|
+
touch.y = e.clientY;
|
|
200
|
+
if (
|
|
201
|
+
this.singleTouchGesture === TOUCH_GESTURE.ROTATE &&
|
|
202
|
+
this.enableRotate
|
|
203
|
+
) {
|
|
204
|
+
this._rotate(dx * this.rotateSpeed, dy * this.rotateSpeed);
|
|
205
|
+
} else {
|
|
206
|
+
this._pan(dx, dy);
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Two fingers: pan by the midpoint's motion and zoom by the pinch.
|
|
212
|
+
// Any finger beyond the first two is tracked but ignored.
|
|
213
|
+
const [a, b] = this._touches.values();
|
|
214
|
+
const midXBefore = (a.x + b.x) / 2;
|
|
215
|
+
const midYBefore = (a.y + b.y) / 2;
|
|
216
|
+
touch.x = e.clientX;
|
|
217
|
+
touch.y = e.clientY;
|
|
218
|
+
const midX = (a.x + b.x) / 2;
|
|
219
|
+
const midY = (a.y + b.y) / 2;
|
|
220
|
+
this._pan(midX - midXBefore, midY - midYBefore);
|
|
221
|
+
|
|
222
|
+
const distance = Math.hypot(a.x - b.x, a.y - b.y);
|
|
223
|
+
if (this._pinchDistance > 0 && distance > 0) {
|
|
224
|
+
// Spreading shrinks the factor below 1 (zoom in), matching the
|
|
225
|
+
// wheel's Math.exp convention.
|
|
226
|
+
const ndc = this._toNdc(midX, midY);
|
|
227
|
+
this._zoom(this._pinchDistance / distance, ndc.x, ndc.y);
|
|
228
|
+
}
|
|
229
|
+
this._pinchDistance = distance;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* The camera math, provided by subclasses. */
|
|
233
|
+
_pan(dx, dy) {}
|
|
234
|
+
_rotate(rx, ry) {}
|
|
235
|
+
_zoom(factor, ndcX, ndcY) {}
|
|
236
|
+
|
|
237
|
+
dispose() {
|
|
238
|
+
if (this._disposed) return;
|
|
239
|
+
this._disposed = true;
|
|
240
|
+
if (this._mousePointerId !== null) {
|
|
241
|
+
this._releasePointer(this._mousePointerId);
|
|
242
|
+
this._mousePointerId = null;
|
|
243
|
+
}
|
|
244
|
+
for (const pointerId of this._touches.keys()) {
|
|
245
|
+
this._releasePointer(pointerId);
|
|
246
|
+
}
|
|
247
|
+
this._touches.clear();
|
|
248
|
+
this._dragging = false;
|
|
249
|
+
this._pinchDistance = 0;
|
|
250
|
+
releaseTouchAction(this.domElement);
|
|
251
|
+
this.domElement.removeEventListener('pointerdown', this._onPointerDown);
|
|
252
|
+
this.domElement.removeEventListener('pointermove', this._onPointerMove);
|
|
253
|
+
this.domElement.removeEventListener('pointerup', this._onPointerUp);
|
|
254
|
+
this.domElement.removeEventListener('pointercancel', this._onPointerUp);
|
|
255
|
+
this.domElement.removeEventListener('contextmenu', this._onContextMenu);
|
|
256
|
+
this.domElement.removeEventListener('wheel', this._onWheel);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An image a shader can sample, plus its sampler settings — plain data,
|
|
3
|
+
* like geometries and materials. Assign one to a material's `map` and
|
|
4
|
+
* the renderer uploads it lazily on first draw (see GpuResources /
|
|
5
|
+
* GpuResources2d, which cache the GPU side per device on `_gpu`).
|
|
6
|
+
*
|
|
7
|
+
* `source` is anything copyExternalImageToTexture accepts: an
|
|
8
|
+
* ImageBitmap, HTMLCanvasElement, OffscreenCanvas or HTMLImageElement.
|
|
9
|
+
* Use `Texture.load(url)` to get one from an image file.
|
|
10
|
+
*
|
|
11
|
+
* One Texture can be shared by renderers on the same or different
|
|
12
|
+
* devices. Each device receives its own GPU texture and sampler.
|
|
13
|
+
*/
|
|
14
|
+
export class Texture {
|
|
15
|
+
/**
|
|
16
|
+
* @param {ImageBitmap|HTMLCanvasElement|OffscreenCanvas|HTMLImageElement} source
|
|
17
|
+
* @param {object} [options]
|
|
18
|
+
* @param {GPUFilterMode} [options.magFilter] 'linear' (default) or 'nearest'
|
|
19
|
+
* @param {GPUFilterMode} [options.minFilter] 'linear' (default) or 'nearest'
|
|
20
|
+
* @param {GPUAddressMode} [options.addressModeU] 'clamp-to-edge' (default),
|
|
21
|
+
* 'repeat' or 'mirror-repeat' — what happens outside 0..1 UVs
|
|
22
|
+
* @param {GPUAddressMode} [options.addressModeV] same, for v
|
|
23
|
+
* @param {boolean} [options.flipY] flip the image vertically on upload;
|
|
24
|
+
* useful when a geometry's v axis points up (e.g. RectGeometry) so
|
|
25
|
+
* images would otherwise appear upside down
|
|
26
|
+
* @param {boolean} [options.mipmaps] generate a full mip chain on
|
|
27
|
+
* upload (default true) so minified sampling doesn't alias; turn
|
|
28
|
+
* off for textures that never shrink on screen (UI, full-screen)
|
|
29
|
+
* @param {boolean} [options.srgb] the image holds sRGB-encoded colors
|
|
30
|
+
* (default true — the normal case for pictures): the GPU decodes
|
|
31
|
+
* samples to linear for shading, and mipmaps are filtered in linear
|
|
32
|
+
* space. Turn off for data textures whose values aren't colors
|
|
33
|
+
*/
|
|
34
|
+
constructor(
|
|
35
|
+
source,
|
|
36
|
+
{
|
|
37
|
+
magFilter = 'linear',
|
|
38
|
+
minFilter = 'linear',
|
|
39
|
+
addressModeU = 'clamp-to-edge',
|
|
40
|
+
addressModeV = 'clamp-to-edge',
|
|
41
|
+
flipY = false,
|
|
42
|
+
mipmaps = true,
|
|
43
|
+
srgb = true,
|
|
44
|
+
} = {},
|
|
45
|
+
) {
|
|
46
|
+
this.source = source;
|
|
47
|
+
this.magFilter = magFilter;
|
|
48
|
+
this.minFilter = minFilter;
|
|
49
|
+
this.addressModeU = addressModeU;
|
|
50
|
+
this.addressModeV = addressModeV;
|
|
51
|
+
this.flipY = flipY;
|
|
52
|
+
this.mipmaps = mipmaps;
|
|
53
|
+
this.srgb = srgb;
|
|
54
|
+
this._gpu = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get width() {
|
|
58
|
+
return this.source.width;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get height() {
|
|
62
|
+
return this.source.height;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Destroys every uploaded GPU texture (if any), releasing its memory
|
|
67
|
+
* right away instead of waiting for GC. The next draw on each device
|
|
68
|
+
* uploads it again, and meshes/shapes rebuild their bind groups.
|
|
69
|
+
*/
|
|
70
|
+
dispose() {
|
|
71
|
+
if (this._gpu) {
|
|
72
|
+
for (const { texture } of this._gpu.values()) texture.destroy();
|
|
73
|
+
this._gpu = null;
|
|
74
|
+
}
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fetches an image file and wraps it in a Texture. */
|
|
79
|
+
static async load(url, options) {
|
|
80
|
+
const response = await fetch(url);
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(`Texture.load: ${url} responded ${response.status}`);
|
|
83
|
+
}
|
|
84
|
+
const bitmap = await createImageBitmap(await response.blob());
|
|
85
|
+
return new Texture(bitmap, options);
|
|
86
|
+
}
|
|
87
|
+
}
|