@driftengine/core 4.8.3 → 4.8.5
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/CHANGELOG.json +82 -0
- package/README.md +1 -1
- package/dist/build/shaderComments.js +0 -0
- package/dist/geometry/meshConcat.js +2 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.js +16 -1
- package/dist/math/scalar.js +1 -1
- package/dist/render/anisotropicLobe.d.ts +7 -0
- package/dist/render/anisotropicLobe.js +33 -0
- package/dist/render/backend/api.d.ts +41 -4
- package/dist/render/backend/select.js +8 -0
- package/dist/render/backend/webgl2/clothTextures.d.ts +27 -0
- package/dist/render/backend/webgl2/clothTextures.js +93 -0
- package/dist/render/backend/webgl2/renderer.d.ts +136 -3
- package/dist/render/backend/webgl2/renderer.js +604 -63
- package/dist/render/backend/webgl2/skinScatter.d.ts +50 -0
- package/dist/render/backend/webgl2/skinScatter.js +256 -0
- package/dist/render/backend/webgpu/buffers.d.ts +25 -11
- package/dist/render/backend/webgpu/buffers.js +98 -49
- package/dist/render/backend/webgpu/clothSolvePack.d.ts +27 -0
- package/dist/render/backend/webgpu/clothSolvePack.js +129 -0
- package/dist/render/backend/webgpu/clothTextures.d.ts +68 -0
- package/dist/render/backend/webgpu/clothTextures.js +145 -0
- package/dist/render/backend/webgpu/compressedUpload.d.ts +18 -0
- package/dist/render/backend/webgpu/compressedUpload.js +38 -0
- package/dist/render/backend/webgpu/computeHost.d.ts +21 -0
- package/dist/render/backend/webgpu/computeHost.js +5 -0
- package/dist/render/backend/webgpu/depthPass.d.ts +16 -6
- package/dist/render/backend/webgpu/depthPass.js +39 -9
- package/dist/render/backend/webgpu/drawCommand.js +6 -2
- package/dist/render/backend/webgpu/flatPass.d.ts +38 -4
- package/dist/render/backend/webgpu/flatPass.js +109 -12
- package/dist/render/backend/webgpu/gpuSkinnedCloth.d.ts +74 -0
- package/dist/render/backend/webgpu/gpuSkinnedCloth.js +452 -0
- package/dist/render/backend/webgpu/motionPass.d.ts +20 -0
- package/dist/render/backend/webgpu/motionPass.js +106 -9
- package/dist/render/backend/webgpu/postPass.d.ts +3 -1
- package/dist/render/backend/webgpu/postPass.js +6 -3
- package/dist/render/backend/webgpu/renderer.d.ts +113 -5
- package/dist/render/backend/webgpu/renderer.js +498 -48
- package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +33 -0
- package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +469 -0
- package/dist/render/backend/webgpu/skinScatterPass.d.ts +51 -0
- package/dist/render/backend/webgpu/skinScatterPass.js +253 -0
- package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
- package/dist/render/backend/webgpu/surfaceTexturePass.js +37 -12
- package/dist/render/clothBindingData.d.ts +41 -0
- package/dist/render/clothBindingData.js +72 -0
- package/dist/render/clothPlacement.d.ts +25 -0
- package/dist/render/clothPlacement.js +85 -0
- package/dist/render/compressedSource.d.ts +66 -0
- package/dist/render/compressedSource.js +138 -0
- package/dist/render/cutoutCaster.d.ts +2 -0
- package/dist/render/cutoutCaster.js +2 -0
- package/dist/render/cutoutDither.d.ts +51 -0
- package/dist/render/cutoutDither.js +90 -0
- package/dist/render/eyeRefraction.d.ts +35 -0
- package/dist/render/eyeRefraction.js +40 -0
- package/dist/render/glCompressed.d.ts +27 -0
- package/dist/render/glCompressed.js +53 -0
- package/dist/render/hairLobes.d.ts +70 -0
- package/dist/render/hairLobes.js +120 -0
- package/dist/render/lightBudget.d.ts +22 -0
- package/dist/render/lightBudget.js +22 -0
- package/dist/render/mesh.d.ts +7 -0
- package/dist/render/mesh.js +24 -0
- package/dist/render/recon/motionVectors.d.ts +4 -2
- package/dist/render/recon/motionVectors.js +6 -3
- package/dist/render/renderQuality.d.ts +20 -0
- package/dist/render/renderQuality.js +5 -0
- package/dist/render/rendererHarness.js +4 -0
- package/dist/render/sceneTarget.d.ts +12 -0
- package/dist/render/sceneTarget.js +14 -0
- package/dist/render/shaders/clothBinding.d.ts +22 -0
- package/dist/render/shaders/clothBinding.js +80 -0
- package/dist/render/shaders/depth.js +42 -6
- package/dist/render/shaders/flat/anisotropic.d.ts +15 -0
- package/dist/render/shaders/flat/anisotropic.js +68 -0
- package/dist/render/shaders/flat/eye.d.ts +1 -0
- package/dist/render/shaders/flat/eye.js +81 -0
- package/dist/render/shaders/flat/hair.d.ts +1 -0
- package/dist/render/shaders/flat/hair.js +101 -0
- package/dist/render/shaders/flat/index.js +36 -2
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +113 -17
- package/dist/render/shaders/flat/models.d.ts +14 -0
- package/dist/render/shaders/flat/models.js +273 -0
- package/dist/render/shaders/flat/preamble.js +26 -24
- package/dist/render/shaders/flat/skin.d.ts +1 -0
- package/dist/render/shaders/flat/skin.js +114 -0
- package/dist/render/shaders/generated/depth.wgsl.d.ts +38 -16
- package/dist/render/shaders/generated/depth.wgsl.js +38 -16
- package/dist/render/shaders/generated/flat.wgsl.d.ts +1790 -1704
- package/dist/render/shaders/generated/flat.wgsl.js +1828 -1742
- package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +48 -0
- package/dist/render/shaders/generated/skinBlur.wgsl.js +54 -0
- package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
- package/dist/render/shaders/recon/motion.wgsl.js +102 -0
- package/dist/render/shaders/skinBlur.d.ts +1 -0
- package/dist/render/shaders/skinBlur.js +103 -0
- package/dist/render/shaders/skinning.d.ts +3 -1
- package/dist/render/shaders/skinning.js +31 -4
- package/dist/render/shadowCasters.d.ts +13 -1
- package/dist/render/skinBlur.d.ts +41 -0
- package/dist/render/skinBlur.js +49 -0
- package/dist/render/skinScatter.d.ts +67 -0
- package/dist/render/skinScatter.js +80 -0
- package/dist/render/skinnedClothRun.d.ts +26 -0
- package/dist/render/skinnedClothRun.js +84 -0
- package/dist/render/surfaceModel.d.ts +129 -0
- package/dist/render/surfaceModel.js +152 -0
- package/dist/render/surfaceTexture.d.ts +44 -4
- package/dist/render/surfaceTexture.js +60 -46
- package/dist/render/vertexDefaults.js +7 -0
- package/package.json +6 -6
- package/src/build/shaderComments.ts +0 -0
- package/src/geometry/meshConcat.ts +2 -0
- package/src/index.ts +40 -1
- package/src/math/scalar.ts +1 -1
- package/src/render/anisotropicLobe.ts +41 -0
- package/src/render/backend/api.ts +52 -3
- package/src/render/backend/select.ts +8 -0
- package/src/render/backend/webgl2/clothTextures.ts +120 -0
- package/src/render/backend/webgl2/renderer.ts +693 -64
- package/src/render/backend/webgl2/skinScatter.ts +309 -0
- package/src/render/backend/webgpu/buffers.ts +137 -63
- package/src/render/backend/webgpu/clothSolvePack.ts +161 -0
- package/src/render/backend/webgpu/clothTextures.ts +194 -0
- package/src/render/backend/webgpu/compressedUpload.ts +65 -0
- package/src/render/backend/webgpu/computeHost.ts +25 -0
- package/src/render/backend/webgpu/depthPass.ts +39 -7
- package/src/render/backend/webgpu/drawCommand.ts +6 -2
- package/src/render/backend/webgpu/flatPass.ts +145 -7
- package/src/render/backend/webgpu/gpuSkinnedCloth.ts +502 -0
- package/src/render/backend/webgpu/motionPass.ts +128 -9
- package/src/render/backend/webgpu/postPass.ts +5 -1
- package/src/render/backend/webgpu/renderer.ts +663 -43
- package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +470 -0
- package/src/render/backend/webgpu/skinScatterPass.ts +309 -0
- package/src/render/backend/webgpu/surfaceTexturePass.ts +42 -12
- package/src/render/clothBindingData.ts +89 -0
- package/src/render/clothPlacement.ts +114 -0
- package/src/render/compressedSource.ts +198 -0
- package/src/render/cutoutCaster.ts +5 -0
- package/src/render/cutoutDither.ts +99 -0
- package/src/render/eyeRefraction.ts +72 -0
- package/src/render/glCompressed.ts +86 -0
- package/src/render/hairLobes.ts +188 -0
- package/src/render/lightBudget.ts +25 -0
- package/src/render/mesh.ts +23 -0
- package/src/render/recon/motionVectors.ts +6 -3
- package/src/render/renderQuality.ts +28 -0
- package/src/render/rendererHarness.ts +4 -0
- package/src/render/sceneTarget.ts +21 -0
- package/src/render/shaders/clothBinding.ts +81 -0
- package/src/render/shaders/depth.ts +42 -6
- package/src/render/shaders/flat/anisotropic.ts +68 -0
- package/src/render/shaders/flat/eye.ts +82 -0
- package/src/render/shaders/flat/hair.ts +102 -0
- package/src/render/shaders/flat/index.ts +36 -2
- package/src/render/shaders/flat/main.ts +113 -17
- package/src/render/shaders/flat/models.ts +286 -0
- package/src/render/shaders/flat/preamble.ts +26 -24
- package/src/render/shaders/flat/skin.ts +116 -0
- package/src/render/shaders/generated/depth.wgsl.ts +38 -16
- package/src/render/shaders/generated/flat.wgsl.ts +1828 -1742
- package/src/render/shaders/generated/skinBlur.wgsl.ts +56 -0
- package/src/render/shaders/recon/motion.wgsl.ts +102 -0
- package/src/render/shaders/skinBlur.ts +105 -0
- package/src/render/shaders/skinning.ts +31 -4
- package/src/render/shadowCasters.ts +13 -0
- package/src/render/skinBlur.ts +53 -0
- package/src/render/skinScatter.ts +92 -0
- package/src/render/skinnedClothRun.ts +128 -0
- package/src/render/surfaceModel.ts +274 -0
- package/src/render/surfaceTexture.ts +123 -63
- package/src/render/vertexDefaults.ts +7 -0
- package/dist/math/exact.d.ts +0 -76
- package/dist/math/exact.js +0 -327
- package/src/math/exact.ts +0 -340
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A block-compressed image a caller hands to `createSurfaceTexture`, and the one decision about it
|
|
3
|
+
* both backends share: whether this device takes it as blocks.
|
|
4
|
+
*
|
|
5
|
+
* **Uploaded as it is, or not at all.** A BC texture costs a byte a texel on the GPU (BC7) or half
|
|
6
|
+
* of one (BC1) against four for RGBA, and keeps the mip chain its author built rather than one a box
|
|
7
|
+
* filter makes. A device that cannot sample the format — most phones, which have ASTC and ETC2 —
|
|
8
|
+
* gets nothing from the blocks, and the engine does not decode them here: core ships no decoder.
|
|
9
|
+
* The loader in `@driftengine/assets` asks `uploadsCompressed` first and decodes at load where the
|
|
10
|
+
* answer is no; a caller handing a source this device cannot take is refused by name.
|
|
11
|
+
*
|
|
12
|
+
* Backend-neutral, because both backends must agree on what they accept before either touches a
|
|
13
|
+
* device. `BlockFormat` is the same union `@driftengine/drft` names `BcFormat`; core imports nothing
|
|
14
|
+
* from that package, and a format added there without a twin here fails where the loader passes one
|
|
15
|
+
* to the other, at compile time.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The layers as blocks, or null where every one is an image. An array mixing the two is refused:
|
|
19
|
+
* one GPU texture has one format.
|
|
20
|
+
*/
|
|
21
|
+
export function compressedLayers(sources) {
|
|
22
|
+
let count = 0;
|
|
23
|
+
for (const source of sources)
|
|
24
|
+
if (isCompressedSource(source))
|
|
25
|
+
count++;
|
|
26
|
+
if (count === 0)
|
|
27
|
+
return null;
|
|
28
|
+
if (count !== sources.length) {
|
|
29
|
+
throw new Error('createSurfaceTextureArray: some layers are blocks and some are images, and one texture has ' +
|
|
30
|
+
'one format. Decode the blocks, or build the array from blocks alone.');
|
|
31
|
+
}
|
|
32
|
+
return sources;
|
|
33
|
+
}
|
|
34
|
+
/** Bytes per 4x4 block. */
|
|
35
|
+
export const BLOCK_BYTES = {
|
|
36
|
+
bc1: 8,
|
|
37
|
+
bc2: 16,
|
|
38
|
+
bc3: 16,
|
|
39
|
+
bc4: 8,
|
|
40
|
+
bc5: 16,
|
|
41
|
+
bc7: 16,
|
|
42
|
+
};
|
|
43
|
+
/** Whether `source` is a compressed source rather than an image. */
|
|
44
|
+
export function isCompressedSource(source) {
|
|
45
|
+
const candidate = source;
|
|
46
|
+
return (typeof candidate === 'object' &&
|
|
47
|
+
candidate !== null &&
|
|
48
|
+
typeof candidate.format === 'string' &&
|
|
49
|
+
Array.isArray(candidate.levels));
|
|
50
|
+
}
|
|
51
|
+
/** The format a device would have to offer, or null for one with no sRGB form. */
|
|
52
|
+
export function compressedFormatName(format, srgb) {
|
|
53
|
+
if (!srgb)
|
|
54
|
+
return format;
|
|
55
|
+
return format === 'bc4' || format === 'bc5' ? null : `${format}-srgb`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether a `width` by `height` source in `format` uploads as blocks on a device offering
|
|
59
|
+
* `available`. Level 0 must be whole blocks each way: WebGPU refuses any other size for a BC
|
|
60
|
+
* texture, and WebGL2's S3TC extension says the same, so a 6x6 image is decoded rather than
|
|
61
|
+
* refused. What would make that wrong is a device that pads; none is relied on here.
|
|
62
|
+
*/
|
|
63
|
+
export function uploadsCompressed(format, srgb, width, height, available) {
|
|
64
|
+
const name = compressedFormatName(format, srgb);
|
|
65
|
+
return name !== null && width % 4 === 0 && height % 4 === 0 && available.includes(name);
|
|
66
|
+
}
|
|
67
|
+
/** The bytes level `level` of a `width` by `height` image takes: whole blocks, one at least. */
|
|
68
|
+
function levelBytes(format, width, height, level) {
|
|
69
|
+
const w = Math.max(1, width >> level);
|
|
70
|
+
const h = Math.max(1, height >> level);
|
|
71
|
+
return Math.ceil(w / 4) * Math.ceil(h / 4) * BLOCK_BYTES[format];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* How many levels the layers carry, after checking each is the size its place requires and every
|
|
75
|
+
* layer matches the first. Refuses by name: a short level uploaded anyway is a device error at
|
|
76
|
+
* submit, which on WebGPU is a frame that draws nothing and says so somewhere else.
|
|
77
|
+
*/
|
|
78
|
+
export function compressedLevels(layers) {
|
|
79
|
+
const first = layers[0];
|
|
80
|
+
if (first === undefined)
|
|
81
|
+
throw new Error('createSurfaceTexture: an array needs at least one image');
|
|
82
|
+
const { format, width, height } = first;
|
|
83
|
+
const count = first.levels.length;
|
|
84
|
+
const most = Math.floor(Math.log2(Math.max(1, width, height))) + 1;
|
|
85
|
+
if (count < 1 || count > most) {
|
|
86
|
+
throw new Error(`createSurfaceTexture: ${count} levels for a ${width}x${height} image, which has at least 1 ` +
|
|
87
|
+
`and at most ${most}`);
|
|
88
|
+
}
|
|
89
|
+
for (let layer = 0; layer < layers.length; layer++) {
|
|
90
|
+
const source = layers[layer];
|
|
91
|
+
if (source.format !== format ||
|
|
92
|
+
source.width !== width ||
|
|
93
|
+
source.height !== height ||
|
|
94
|
+
source.levels.length !== count) {
|
|
95
|
+
throw new Error(`createSurfaceTexture: layer ${layer} is ${source.width}x${source.height} ` +
|
|
96
|
+
`${source.format.toUpperCase()} with ${source.levels.length} levels, and layer 0 is ` +
|
|
97
|
+
`${width}x${height} ${format.toUpperCase()} with ${count}. Every layer of an array is one ` +
|
|
98
|
+
'format, size and chain on the device.');
|
|
99
|
+
}
|
|
100
|
+
for (let level = 0; level < count; level++) {
|
|
101
|
+
const want = levelBytes(format, width, height, level);
|
|
102
|
+
const got = source.levels[level].length;
|
|
103
|
+
if (got !== want) {
|
|
104
|
+
throw new Error(`createSurfaceTexture: level ${level} of a ${width}x${height} ${format.toUpperCase()} ` +
|
|
105
|
+
`image is ${want} bytes, and ${got} were given`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return count;
|
|
110
|
+
}
|
|
111
|
+
/** What a refusal names: the format, its colour space, and what the device offers instead. */
|
|
112
|
+
export function compressedRefusal(format, srgb, width, height, available) {
|
|
113
|
+
const asked = `${format.toUpperCase()}${srgb ? ' sRGB' : ''} at ${width}x${height}`;
|
|
114
|
+
const offered = available.length === 0 ? 'no BC format' : available.join(', ');
|
|
115
|
+
return (`createSurfaceTexture: this device cannot take ${asked} as blocks — it offers ${offered}, and ` +
|
|
116
|
+
'a BC texture must be whole 4x4 blocks at level 0. Decode it first (decodeBc in ' +
|
|
117
|
+
'@driftengine/assets), or ask renderer.compressedFormats before choosing.');
|
|
118
|
+
}
|
|
119
|
+
/** The format name and level count blocks upload as here, or a refusal naming why they cannot. */
|
|
120
|
+
export function planBlocks(blocks, srgb, compressed) {
|
|
121
|
+
const { format, width, height } = blocks[0];
|
|
122
|
+
const name = compressedFormatName(format, srgb);
|
|
123
|
+
if (name === null || !uploadsCompressed(format, srgb, width, height, compressed)) {
|
|
124
|
+
throw new Error(compressedRefusal(format, srgb, width, height, compressed));
|
|
125
|
+
}
|
|
126
|
+
return { name, levels: compressedLevels(blocks) };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* `update` replaces pixels with an image, and a texture holding blocks has no pixels to replace:
|
|
130
|
+
* its format is the blocks', and an image would be a different texture. Refused rather than
|
|
131
|
+
* quietly reallocated, so the handle a draw holds keeps meaning what it meant.
|
|
132
|
+
*/
|
|
133
|
+
export function refuseBlockUpdate(format) {
|
|
134
|
+
if (format === null)
|
|
135
|
+
return;
|
|
136
|
+
throw new Error(`updateSurfaceTexture: this texture holds ${format.toUpperCase()} blocks, and update replaces ` +
|
|
137
|
+
'an image. Build a new texture for new contents.');
|
|
138
|
+
}
|
|
@@ -16,6 +16,8 @@ import type { SurfaceMaterial } from './surfaceTexture.ts';
|
|
|
16
16
|
export interface CutoutCaster<Texture> {
|
|
17
17
|
readonly albedo: Texture;
|
|
18
18
|
readonly cutoff: number;
|
|
19
|
+
/** 1 for a dithered edge, which a caster draws with a pattern that does not move. */
|
|
20
|
+
readonly dithered: number;
|
|
19
21
|
readonly u: number;
|
|
20
22
|
readonly v: number;
|
|
21
23
|
readonly uOffset: number;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const answer = {
|
|
2
2
|
albedo: null,
|
|
3
3
|
cutoff: 0,
|
|
4
|
+
dithered: 0,
|
|
4
5
|
u: 1,
|
|
5
6
|
v: 1,
|
|
6
7
|
uOffset: 0,
|
|
@@ -16,6 +17,7 @@ export function cutoutOf(material) {
|
|
|
16
17
|
return null;
|
|
17
18
|
answer.albedo = albedo;
|
|
18
19
|
answer.cutoff = cutoff;
|
|
20
|
+
answer.dithered = material.cutoutMode === 'dithered' ? 1 : 0;
|
|
19
21
|
answer.u = material.uScale ?? 1;
|
|
20
22
|
answer.v = material.vScale ?? 1;
|
|
21
23
|
answer.uOffset = material.uOffset ?? 0;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cutout whose edge is a share of the pixel rather than a line through it.
|
|
3
|
+
*
|
|
4
|
+
* **Why.** A hard alpha test draws a strand of hair, an eyelash or a fringe of leaves as a stair of
|
|
5
|
+
* whole pixels: a card's alpha falls off over a few texels and the test turns that into on and off.
|
|
6
|
+
* A dithered cutout keeps each pixel with a probability, the share of it the texture covers, and
|
|
7
|
+
* something downstream then averages the pattern back into coverage. What can average it depends on
|
|
8
|
+
* the frame, so the material asks for the smooth edge and the renderer decides, per frame, how:
|
|
9
|
+
*
|
|
10
|
+
* - **A temporal resolve (TAA or DriftTR)**: the pixel is kept against a threshold that moves every
|
|
11
|
+
* frame, and the resolve integrates the pattern over its history into coverage.
|
|
12
|
+
* - **A multisampled frame**: the share goes to the GPU as alpha-to-coverage, which turns it into
|
|
13
|
+
* that many of the pixel's samples.
|
|
14
|
+
* - **Neither**: the hard test, because a dither nothing resolves is grain on the screen.
|
|
15
|
+
*
|
|
16
|
+
* **What it gives up**: a dithered edge under a temporal resolve is soft by one history's worth of
|
|
17
|
+
* blur and ghosts slightly behind a fast card, which is the resolve's own trade; and under
|
|
18
|
+
* multisampling the share is quantised to the sample count, five levels at four samples. **What
|
|
19
|
+
* would make it wrong** is a pass that tests depth for equality against the main pass and discards
|
|
20
|
+
* differently from it; the caster and the lit pass take their keep from the same two functions here.
|
|
21
|
+
*/
|
|
22
|
+
/** What a material asks for. `'hard'` is every cutout that shipped before this. */
|
|
23
|
+
export type CutoutMode = 'hard' | 'dithered';
|
|
24
|
+
/** What the frame does with it: 0, 1 and 2 in the shader's `uCutout.y`. */
|
|
25
|
+
export type CutoutResolve = 'hard' | 'dither' | 'coverage';
|
|
26
|
+
/** The shader's number for a resolve. */
|
|
27
|
+
export declare const CUTOUT_RESOLVE_CODE: Readonly<Record<CutoutResolve, number>>;
|
|
28
|
+
/**
|
|
29
|
+
* How a cutout is drawn this frame. A temporal resolve is preferred over multisampling when a frame
|
|
30
|
+
* has both, because it resolves every share and multisampling only the sample count's.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveCutout(mode: CutoutMode, temporal: boolean, samples: number): CutoutResolve;
|
|
33
|
+
/**
|
|
34
|
+
* The share of a pixel a cutout keeps, from its alpha: half at the cutoff, ramping over a band
|
|
35
|
+
* twice the distance to the nearer end. At a cutoff of a half that is alpha itself.
|
|
36
|
+
*/
|
|
37
|
+
export declare function cutoutShare(alpha: number, cutoff: number): number;
|
|
38
|
+
/**
|
|
39
|
+
* The threshold a pixel's share is compared against: interleaved gradient noise (Jimenez,
|
|
40
|
+
* "Next Generation Post Processing in Call of Duty: Advanced Warfare", 2014), offset by the frame so
|
|
41
|
+
* the pattern moves. `x` and `y` are pixel centres, as `gl_FragCoord.xy` is.
|
|
42
|
+
*/
|
|
43
|
+
export declare function ditherThreshold(x: number, y: number, frame: number): number;
|
|
44
|
+
/**
|
|
45
|
+
* The two functions in GLSL, for the lit pass and every caster that keeps the same pixels.
|
|
46
|
+
*
|
|
47
|
+
* `cutoutKeeps(share, pixel, frame)` is the dithered test; `cutoutShare(alpha, cutoff)` is also what
|
|
48
|
+
* a coverage frame writes as alpha. Frame is a float the renderer counts and wraps, so the noise's
|
|
49
|
+
* argument stays small enough for a half-precision device.
|
|
50
|
+
*/
|
|
51
|
+
export declare const CUTOUT_DITHER_GLSL: string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cutout whose edge is a share of the pixel rather than a line through it.
|
|
3
|
+
*
|
|
4
|
+
* **Why.** A hard alpha test draws a strand of hair, an eyelash or a fringe of leaves as a stair of
|
|
5
|
+
* whole pixels: a card's alpha falls off over a few texels and the test turns that into on and off.
|
|
6
|
+
* A dithered cutout keeps each pixel with a probability, the share of it the texture covers, and
|
|
7
|
+
* something downstream then averages the pattern back into coverage. What can average it depends on
|
|
8
|
+
* the frame, so the material asks for the smooth edge and the renderer decides, per frame, how:
|
|
9
|
+
*
|
|
10
|
+
* - **A temporal resolve (TAA or DriftTR)**: the pixel is kept against a threshold that moves every
|
|
11
|
+
* frame, and the resolve integrates the pattern over its history into coverage.
|
|
12
|
+
* - **A multisampled frame**: the share goes to the GPU as alpha-to-coverage, which turns it into
|
|
13
|
+
* that many of the pixel's samples.
|
|
14
|
+
* - **Neither**: the hard test, because a dither nothing resolves is grain on the screen.
|
|
15
|
+
*
|
|
16
|
+
* **What it gives up**: a dithered edge under a temporal resolve is soft by one history's worth of
|
|
17
|
+
* blur and ghosts slightly behind a fast card, which is the resolve's own trade; and under
|
|
18
|
+
* multisampling the share is quantised to the sample count, five levels at four samples. **What
|
|
19
|
+
* would make it wrong** is a pass that tests depth for equality against the main pass and discards
|
|
20
|
+
* differently from it; the caster and the lit pass take their keep from the same two functions here.
|
|
21
|
+
*/
|
|
22
|
+
/** The shader's number for a resolve. */
|
|
23
|
+
export const CUTOUT_RESOLVE_CODE = Object.freeze({
|
|
24
|
+
hard: 0,
|
|
25
|
+
dither: 1,
|
|
26
|
+
coverage: 2,
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* How a cutout is drawn this frame. A temporal resolve is preferred over multisampling when a frame
|
|
30
|
+
* has both, because it resolves every share and multisampling only the sample count's.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveCutout(mode, temporal, samples) {
|
|
33
|
+
if (mode !== 'dithered')
|
|
34
|
+
return 'hard';
|
|
35
|
+
if (temporal)
|
|
36
|
+
return 'dither';
|
|
37
|
+
if (samples > 1)
|
|
38
|
+
return 'coverage';
|
|
39
|
+
return 'hard';
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The share of a pixel a cutout keeps, from its alpha: half at the cutoff, ramping over a band
|
|
43
|
+
* twice the distance to the nearer end. At a cutoff of a half that is alpha itself.
|
|
44
|
+
*/
|
|
45
|
+
export function cutoutShare(alpha, cutoff) {
|
|
46
|
+
const band = Math.max(2 * Math.min(cutoff, 1 - cutoff), 1e-4);
|
|
47
|
+
return Math.min(Math.max((alpha - cutoff) / band + 0.5, 0), 1);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The temporal step of the noise below, per frame: Jimenez's constant (2014), which moves the
|
|
51
|
+
* pattern across the tile rather than stepping each pixel's threshold.
|
|
52
|
+
*
|
|
53
|
+
* **A golden-ratio step per pixel was tried and measured, and it is not better.** It spreads a still
|
|
54
|
+
* pixel's thresholds evenly over time, which reads like the cure for grain; on a card of strands
|
|
55
|
+
* under TAA it left 12.9 levels of grain against this one's 11.7. The floor is the resolve's own: it
|
|
56
|
+
* keeps nine tenths of its history, so the newest frame's kept-or-not pattern is a tenth of the
|
|
57
|
+
* picture, about ten levels of grain at a half share whatever sequence chose it.
|
|
58
|
+
*/
|
|
59
|
+
const NOISE_FRAME_STEP = 5.588238;
|
|
60
|
+
/**
|
|
61
|
+
* The threshold a pixel's share is compared against: interleaved gradient noise (Jimenez,
|
|
62
|
+
* "Next Generation Post Processing in Call of Duty: Advanced Warfare", 2014), offset by the frame so
|
|
63
|
+
* the pattern moves. `x` and `y` are pixel centres, as `gl_FragCoord.xy` is.
|
|
64
|
+
*/
|
|
65
|
+
export function ditherThreshold(x, y, frame) {
|
|
66
|
+
const px = x + NOISE_FRAME_STEP * frame;
|
|
67
|
+
const py = y + NOISE_FRAME_STEP * frame;
|
|
68
|
+
const inner = 0.06711056 * px + 0.00583715 * py;
|
|
69
|
+
const f = 52.9829189 * (inner - Math.floor(inner));
|
|
70
|
+
return f - Math.floor(f);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The two functions in GLSL, for the lit pass and every caster that keeps the same pixels.
|
|
74
|
+
*
|
|
75
|
+
* `cutoutKeeps(share, pixel, frame)` is the dithered test; `cutoutShare(alpha, cutoff)` is also what
|
|
76
|
+
* a coverage frame writes as alpha. Frame is a float the renderer counts and wraps, so the noise's
|
|
77
|
+
* argument stays small enough for a half-precision device.
|
|
78
|
+
*/
|
|
79
|
+
export const CUTOUT_DITHER_GLSL = `
|
|
80
|
+
float cutoutShare(float alpha, float cutoff) {
|
|
81
|
+
float band = max(2.0 * min(cutoff, 1.0 - cutoff), 1e-4);
|
|
82
|
+
return clamp((alpha - cutoff) / band + 0.5, 0.0, 1.0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
bool cutoutKeeps(float share, vec2 pixel, float frame) {
|
|
86
|
+
vec2 p = pixel + vec2(${NOISE_FRAME_STEP.toFixed(6)} * frame);
|
|
87
|
+
float noise = fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
|
|
88
|
+
return share > noise;
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The eye model's refraction, as `shaders/flat/eye.ts` computes it: the reference its tests hold the
|
|
3
|
+
* arithmetic to, and nothing at run time calls it.
|
|
4
|
+
*
|
|
5
|
+
* **An iris is seen through a cornea**, a few millimetres of clear fluid above it with an index near
|
|
6
|
+
* water's, so it is not where it is painted: the eye's ray bends at the cornea toward the axis and
|
|
7
|
+
* meets the iris plane somewhere else, and the iris seems to lie deeper and to shift as the eye
|
|
8
|
+
* turns away. The lit stage moves the iris's texture coordinate by that shift before any map of
|
|
9
|
+
* the material is read, so the whole iris — colour, relief, its own channels — moves as one.
|
|
10
|
+
*
|
|
11
|
+
* **The shift**: refract the view at the cornea's surface, follow the refracted ray down until it
|
|
12
|
+
* has descended the cornea's height above the iris plane along the eye's axis, and take what it
|
|
13
|
+
* travelled across the plane. Head-on it is nothing; at an angle θ to a flat cornea it is
|
|
14
|
+
* `height · tan(asin(sin θ / ior))`. The plane is perpendicular to the axis, so a cornea seen
|
|
15
|
+
* almost edge-on sends the ray along it: the descent is floored, which bounds the shift at about a
|
|
16
|
+
* thousand times the height rather than letting it run to infinity at the limb.
|
|
17
|
+
*/
|
|
18
|
+
import type { Vec3 } from '../math/color.ts';
|
|
19
|
+
/** Schlick's base for the cornea's highlight, at the model's default index 1.336. */
|
|
20
|
+
export declare const CORNEA_F0: number;
|
|
21
|
+
/** How slowly a refracted ray may descend toward the iris plane before it is held there. */
|
|
22
|
+
export declare const EYE_MIN_DESCENT = 0.001;
|
|
23
|
+
/** GLSL's `refract`, into `out`: the transmitted direction, or zeros past total internal reflection. */
|
|
24
|
+
export declare function refract(incident: Vec3, normal: Vec3, eta: number, out: Float64Array): void;
|
|
25
|
+
/**
|
|
26
|
+
* How far across the iris plane the eye's ray lands from where it entered the cornea, in metres,
|
|
27
|
+
* into `out`: `toEye` and `normal` at the cornea, `axis` the eye's, all unit vectors; `height` the
|
|
28
|
+
* cornea's height above the iris plane there.
|
|
29
|
+
*/
|
|
30
|
+
export declare function eyeRefractionShift(toEye: Vec3, normal: Vec3, axis: Vec3, ior: number, height: number, out: Float64Array): void;
|
|
31
|
+
/**
|
|
32
|
+
* Where the iris is, without a map: 1 inside `irisRadius` of the texture's centre, 0 outside, across
|
|
33
|
+
* an edge `edge` wide — a limbus, not a cut.
|
|
34
|
+
*/
|
|
35
|
+
export declare function irisMask(u: number, v: number, irisRadius: number, edge: number): number;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Schlick's base for the cornea's highlight, at the model's default index 1.336. */
|
|
2
|
+
export const CORNEA_F0 = ((1 - 1.336) / (1 + 1.336)) ** 2;
|
|
3
|
+
/** How slowly a refracted ray may descend toward the iris plane before it is held there. */
|
|
4
|
+
export const EYE_MIN_DESCENT = 1e-3;
|
|
5
|
+
/** GLSL's `refract`, into `out`: the transmitted direction, or zeros past total internal reflection. */
|
|
6
|
+
export function refract(incident, normal, eta, out) {
|
|
7
|
+
const d = normal[0] * incident[0] + normal[1] * incident[1] + normal[2] * incident[2];
|
|
8
|
+
const k = 1 - eta * eta * (1 - d * d);
|
|
9
|
+
if (k < 0) {
|
|
10
|
+
out.fill(0);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const s = eta * d + Math.sqrt(k);
|
|
14
|
+
for (let c = 0; c < 3; c++)
|
|
15
|
+
out[c] = eta * incident[c] - s * normal[c];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* How far across the iris plane the eye's ray lands from where it entered the cornea, in metres,
|
|
19
|
+
* into `out`: `toEye` and `normal` at the cornea, `axis` the eye's, all unit vectors; `height` the
|
|
20
|
+
* cornea's height above the iris plane there.
|
|
21
|
+
*/
|
|
22
|
+
export function eyeRefractionShift(toEye, normal, axis, ior, height, out) {
|
|
23
|
+
const ray = new Float64Array(3);
|
|
24
|
+
refract([-toEye[0], -toEye[1], -toEye[2]], normal, 1 / ior, ray);
|
|
25
|
+
const descent = Math.max(-(ray[0] * axis[0] + ray[1] * axis[1] + ray[2] * axis[2]), EYE_MIN_DESCENT);
|
|
26
|
+
const reach = height / descent;
|
|
27
|
+
const along = (ray[0] * axis[0] + ray[1] * axis[1] + ray[2] * axis[2]) *
|
|
28
|
+
reach;
|
|
29
|
+
for (let c = 0; c < 3; c++)
|
|
30
|
+
out[c] = ray[c] * reach - axis[c] * along;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Where the iris is, without a map: 1 inside `irisRadius` of the texture's centre, 0 outside, across
|
|
34
|
+
* an edge `edge` wide — a limbus, not a cut.
|
|
35
|
+
*/
|
|
36
|
+
export function irisMask(u, v, irisRadius, edge) {
|
|
37
|
+
const r = Math.hypot(u - 0.5, v - 0.5);
|
|
38
|
+
const t = Math.min(Math.max((r - (irisRadius - edge)) / (2 * edge), 0), 1);
|
|
39
|
+
return 1 - t * t * (3 - 2 * t);
|
|
40
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BC on WebGL2: which formats this context samples, and the upload of a stored chain.
|
|
3
|
+
*
|
|
4
|
+
* **Four extensions, each a different subset**, and the names a device offers are read off them:
|
|
5
|
+
* `WEBGL_compressed_texture_s3tc` is BC1 to BC3, its `_srgb` twin their sRGB forms,
|
|
6
|
+
* `EXT_texture_compression_rgtc` BC4 and BC5, and `EXT_texture_compression_bptc` BC7 in both. A
|
|
7
|
+
* desktop GPU usually has all four and a phone none, which is why the loader decodes there.
|
|
8
|
+
*
|
|
9
|
+
* BC1 is uploaded as `COMPRESSED_RGBA_S3TC_DXT1_EXT` rather than the RGB one, so a three-colour
|
|
10
|
+
* block's fourth index is transparent here as it is on WebGPU, whose only BC1 is `bc1-rgba-unorm`.
|
|
11
|
+
*
|
|
12
|
+
* The stored levels are uploaded as they are and no chain is generated: `generateMipmap` cannot
|
|
13
|
+
* write a compressed format, and the chain its author stored is the better one anyway. A chain that
|
|
14
|
+
* stops before 1x1 sets `TEXTURE_MAX_LEVEL` to its last level, or the texture is incomplete and
|
|
15
|
+
* samples black.
|
|
16
|
+
*/
|
|
17
|
+
import type { CompressedTextureFormat, CompressedTextureSource } from './compressedSource.ts';
|
|
18
|
+
/**
|
|
19
|
+
* The BC formats this context samples, enabling each extension that offers some. Enabling is what
|
|
20
|
+
* `getExtension` does, and a format is only legal in an upload once it has been called.
|
|
21
|
+
*/
|
|
22
|
+
export declare function glCompressedFormats(gl: WebGL2RenderingContext): CompressedTextureFormat[];
|
|
23
|
+
/**
|
|
24
|
+
* Upload `layers`, every one of `levels` levels, to the bound `TEXTURE_2D_ARRAY` as `name`, and cap
|
|
25
|
+
* the chain at what was stored. The caller has checked the sources and the device.
|
|
26
|
+
*/
|
|
27
|
+
export declare function uploadCompressedArray(gl: WebGL2RenderingContext, name: CompressedTextureFormat, layers: readonly CompressedTextureSource[], levels: number): void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The constants each extension defines, written out so a missing extension cannot be read for one. */
|
|
2
|
+
const INTERNAL = {
|
|
3
|
+
bc1: 0x83f1, // COMPRESSED_RGBA_S3TC_DXT1_EXT
|
|
4
|
+
bc2: 0x83f2, // COMPRESSED_RGBA_S3TC_DXT3_EXT
|
|
5
|
+
bc3: 0x83f3, // COMPRESSED_RGBA_S3TC_DXT5_EXT
|
|
6
|
+
'bc1-srgb': 0x8c4d, // COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT
|
|
7
|
+
'bc2-srgb': 0x8c4e, // COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT
|
|
8
|
+
'bc3-srgb': 0x8c4f, // COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT
|
|
9
|
+
bc4: 0x8dbb, // COMPRESSED_RED_RGTC1_EXT
|
|
10
|
+
bc5: 0x8dbd, // COMPRESSED_RED_GREEN_RGTC2_EXT
|
|
11
|
+
bc7: 0x8e8c, // COMPRESSED_RGBA_BPTC_UNORM_EXT
|
|
12
|
+
'bc7-srgb': 0x8e8d, // COMPRESSED_SRGB_ALPHA_BPTC_UNORM_EXT
|
|
13
|
+
};
|
|
14
|
+
const BY_EXTENSION = [
|
|
15
|
+
['WEBGL_compressed_texture_s3tc', ['bc1', 'bc2', 'bc3']],
|
|
16
|
+
['WEBGL_compressed_texture_s3tc_srgb', ['bc1-srgb', 'bc2-srgb', 'bc3-srgb']],
|
|
17
|
+
['EXT_texture_compression_rgtc', ['bc4', 'bc5']],
|
|
18
|
+
['EXT_texture_compression_bptc', ['bc7', 'bc7-srgb']],
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* The BC formats this context samples, enabling each extension that offers some. Enabling is what
|
|
22
|
+
* `getExtension` does, and a format is only legal in an upload once it has been called.
|
|
23
|
+
*/
|
|
24
|
+
export function glCompressedFormats(gl) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const [name, formats] of BY_EXTENSION) {
|
|
27
|
+
if (gl.getExtension(name) !== null)
|
|
28
|
+
out.push(...formats);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Upload `layers`, every one of `levels` levels, to the bound `TEXTURE_2D_ARRAY` as `name`, and cap
|
|
34
|
+
* the chain at what was stored. The caller has checked the sources and the device.
|
|
35
|
+
*/
|
|
36
|
+
export function uploadCompressedArray(gl, name, layers, levels) {
|
|
37
|
+
const first = layers[0];
|
|
38
|
+
for (let level = 0; level < levels; level++) {
|
|
39
|
+
let size = 0;
|
|
40
|
+
for (const layer of layers)
|
|
41
|
+
size += layer.levels[level].length;
|
|
42
|
+
const data = new Uint8Array(size);
|
|
43
|
+
let at = 0;
|
|
44
|
+
for (const layer of layers) {
|
|
45
|
+
const bytes = layer.levels[level];
|
|
46
|
+
data.set(bytes, at);
|
|
47
|
+
at += bytes.length;
|
|
48
|
+
}
|
|
49
|
+
gl.compressedTexImage3D(gl.TEXTURE_2D_ARRAY, level, INTERNAL[name], Math.max(1, first.width >> level), Math.max(1, first.height >> level), layers.length, 0, data);
|
|
50
|
+
}
|
|
51
|
+
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_BASE_LEVEL, 0);
|
|
52
|
+
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAX_LEVEL, levels - 1);
|
|
53
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hair model's lobes, as `shaders/flat/hair.ts` computes them: the reference its tests hold the
|
|
3
|
+
* arithmetic to, and nothing at run time calls it.
|
|
4
|
+
*
|
|
5
|
+
* **A strand is a rough dielectric cylinder, index 1.55, after Marschner et al. 2003 in the
|
|
6
|
+
* real-time form Karis gave in 2016.** Light leaves it three ways, each a longitudinal Gaussian in
|
|
7
|
+
* `sinθi + sinθo` — the angles of light and eye out of the plane normal to the strand — times an
|
|
8
|
+
* azimuthal term in φ, the turn between them around it:
|
|
9
|
+
*
|
|
10
|
+
* - **R**, reflected off the surface: white, and shifted toward the root by twice the tilt of the
|
|
11
|
+
* cuticle's scales, which point toward the tip;
|
|
12
|
+
* - **TT**, through the strand and out: the glow of a backlit head, shifted toward the tip by the
|
|
13
|
+
* tilt, half as wide, and tinted by the fibre for the path it took;
|
|
14
|
+
* - **TRT**, reflected once inside: the coloured second highlight, three tilts toward the tip and
|
|
15
|
+
* twice as wide;
|
|
16
|
+
*
|
|
17
|
+
* and a fourth, the light scattered through many strands, as a wrapped diffuse against a normal
|
|
18
|
+
* turned toward the eye across the strand, tinted by a root of the fibre's colour.
|
|
19
|
+
*
|
|
20
|
+
* **The tangent runs from root to tip**, and the tilt is positive for a strand authored that way. A
|
|
21
|
+
* card authored the other way round swaps its two highlights' ends, and takes a negative shift.
|
|
22
|
+
*
|
|
23
|
+
* **In this engine's light units.** A lamp's diffuse here is `albedo · cosθ` with no 1/π, so the
|
|
24
|
+
* published lobes, which are per unit irradiance, are multiplied by π; the scatter term's 1/π cancels.
|
|
25
|
+
* **What it gives up** is the fit's own: these are Karis's approximations to the azimuthal
|
|
26
|
+
* integrals, not the integrals, and they do not conserve energy exactly — the tests hold the sum
|
|
27
|
+
* under what a strand receives.
|
|
28
|
+
*/
|
|
29
|
+
import type { Vec3 } from '../math/color.ts';
|
|
30
|
+
/** Schlick's base for a strand at index 1.55: `((1 − n) / (1 + n))²`. */
|
|
31
|
+
export declare const HAIR_F0: number;
|
|
32
|
+
/**
|
|
33
|
+
* The narrowest longitudinal width a strand is drawn at, in radians.
|
|
34
|
+
*
|
|
35
|
+
* **Below every measured fibre**: Marschner gives the reflected lobe 5° to 10°, and this is about
|
|
36
|
+
* 3°, so no real hair is clamped. What it stops is a roughness of 0 making a Gaussian narrower than
|
|
37
|
+
* a pixel can sample — the speckle `MIN_LOBE_ALPHA` argues against for the standard lobe.
|
|
38
|
+
*/
|
|
39
|
+
export declare const HAIR_MIN_BETA = 0.05;
|
|
40
|
+
/**
|
|
41
|
+
* The scatter term's answer to light arriving evenly from every side, per unit of it: the term's
|
|
42
|
+
* shape integrated over the sphere and divided by π, which is how this engine's lamp units add up.
|
|
43
|
+
*
|
|
44
|
+
* Derived: the wrapped half `(N·L + 1) / 4` averages 1/4 over the sphere, `1 − |T·L|` averages 1/2,
|
|
45
|
+
* and the shape is `mix(wrapped, 1 − |T·L|, 0.33)`, so its mean is 0.67 · 0.25 + 0.33 · 0.5 = 0.3325
|
|
46
|
+
* and its integral 4π times that. Over π: **1.33**.
|
|
47
|
+
*/
|
|
48
|
+
export declare const HAIR_SCATTER_SPHERE = 1.33;
|
|
49
|
+
/** A longitudinal lobe: a normalised Gaussian of width `beta` at `x` from its centre. */
|
|
50
|
+
export declare function hairLongitudinal(beta: number, x: number): number;
|
|
51
|
+
/** Schlick's Fresnel for the strand at a cosine, clamped to the unit interval. */
|
|
52
|
+
export declare function hairFresnel(cosine: number): number;
|
|
53
|
+
/** Where `hairLight` leaves its answer: R, then TT, TRT and the scatter, each a colour. */
|
|
54
|
+
export declare const HAIR_OUT: {
|
|
55
|
+
readonly r: 0;
|
|
56
|
+
readonly tt: 1;
|
|
57
|
+
readonly trt: 4;
|
|
58
|
+
readonly scatter: 7;
|
|
59
|
+
readonly length: 10;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* One light on a strand, in lamp units and unshadowed, into `out` (`HAIR_OUT`'s layout: R is grey,
|
|
63
|
+
* the rest colours). `tangent`, `toLight` and `toEye` are unit vectors but for a tangent that could
|
|
64
|
+
* not be made one, which comes in as zeros; `fibre` is the base colour.
|
|
65
|
+
*
|
|
66
|
+
* **A lamp with a size widens every lobe by the angle it subtends**, `sourceRadius / 2·dist` as
|
|
67
|
+
* `sphereLobe` takes it, and the lobes are normalised Gaussians, so a wider one carries the same
|
|
68
|
+
* light rather than more. The sun passes zeros.
|
|
69
|
+
*/
|
|
70
|
+
export declare function hairLight(tangent: ArrayLike<number>, toLight: ArrayLike<number>, toEye: ArrayLike<number>, fibre: Vec3, roughness: number, shift: number, scatter: number, backlit: number, sourceRadius: number, dist: number, out: Float64Array): void;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Schlick's base for a strand at index 1.55: `((1 − n) / (1 + n))²`. */
|
|
2
|
+
export const HAIR_F0 = ((1 - 1.55) / (1 + 1.55)) ** 2;
|
|
3
|
+
/**
|
|
4
|
+
* The narrowest longitudinal width a strand is drawn at, in radians.
|
|
5
|
+
*
|
|
6
|
+
* **Below every measured fibre**: Marschner gives the reflected lobe 5° to 10°, and this is about
|
|
7
|
+
* 3°, so no real hair is clamped. What it stops is a roughness of 0 making a Gaussian narrower than
|
|
8
|
+
* a pixel can sample — the speckle `MIN_LOBE_ALPHA` argues against for the standard lobe.
|
|
9
|
+
*/
|
|
10
|
+
export const HAIR_MIN_BETA = 0.05;
|
|
11
|
+
/**
|
|
12
|
+
* The scatter term's answer to light arriving evenly from every side, per unit of it: the term's
|
|
13
|
+
* shape integrated over the sphere and divided by π, which is how this engine's lamp units add up.
|
|
14
|
+
*
|
|
15
|
+
* Derived: the wrapped half `(N·L + 1) / 4` averages 1/4 over the sphere, `1 − |T·L|` averages 1/2,
|
|
16
|
+
* and the shape is `mix(wrapped, 1 − |T·L|, 0.33)`, so its mean is 0.67 · 0.25 + 0.33 · 0.5 = 0.3325
|
|
17
|
+
* and its integral 4π times that. Over π: **1.33**.
|
|
18
|
+
*/
|
|
19
|
+
export const HAIR_SCATTER_SPHERE = 1.33;
|
|
20
|
+
/** How much the scatter term leans toward the strand's own direction rather than the pseudo-normal. */
|
|
21
|
+
const KAJIYA_SHARE = 0.33;
|
|
22
|
+
/** A longitudinal lobe: a normalised Gaussian of width `beta` at `x` from its centre. */
|
|
23
|
+
export function hairLongitudinal(beta, x) {
|
|
24
|
+
return Math.exp((-0.5 * x * x) / (beta * beta)) / (Math.sqrt(2 * Math.PI) * beta);
|
|
25
|
+
}
|
|
26
|
+
/** Schlick's Fresnel for the strand at a cosine, clamped to the unit interval. */
|
|
27
|
+
export function hairFresnel(cosine) {
|
|
28
|
+
const c = 1 - Math.min(Math.max(cosine, 0), 1);
|
|
29
|
+
return HAIR_F0 + (1 - HAIR_F0) * c * c * c * c * c;
|
|
30
|
+
}
|
|
31
|
+
function dot(a, b) {
|
|
32
|
+
return (a[0] * b[0] +
|
|
33
|
+
a[1] * b[1] +
|
|
34
|
+
a[2] * b[2]);
|
|
35
|
+
}
|
|
36
|
+
const clamp = (x, low, high) => Math.min(Math.max(x, low), high);
|
|
37
|
+
/** Where `hairLight` leaves its answer: R, then TT, TRT and the scatter, each a colour. */
|
|
38
|
+
export const HAIR_OUT = { r: 0, tt: 1, trt: 4, scatter: 7, length: 10 };
|
|
39
|
+
/**
|
|
40
|
+
* One light on a strand, in lamp units and unshadowed, into `out` (`HAIR_OUT`'s layout: R is grey,
|
|
41
|
+
* the rest colours). `tangent`, `toLight` and `toEye` are unit vectors but for a tangent that could
|
|
42
|
+
* not be made one, which comes in as zeros; `fibre` is the base colour.
|
|
43
|
+
*
|
|
44
|
+
* **A lamp with a size widens every lobe by the angle it subtends**, `sourceRadius / 2·dist` as
|
|
45
|
+
* `sphereLobe` takes it, and the lobes are normalised Gaussians, so a wider one carries the same
|
|
46
|
+
* light rather than more. The sun passes zeros.
|
|
47
|
+
*/
|
|
48
|
+
export function hairLight(tangent, toLight, toEye, fibre, roughness, shift, scatter, backlit, sourceRadius, dist, out) {
|
|
49
|
+
const sinI = clamp(dot(tangent, toLight), -1, 1);
|
|
50
|
+
const sinO = clamp(dot(tangent, toEye), -1, 1);
|
|
51
|
+
/*
|
|
52
|
+
* The half difference angle, rounded to single precision as the device holds it: at its limit,
|
|
53
|
+
* light and eye at opposite ends of the strand, that is π/2 rounded up, whose cosine is −4.4e-8
|
|
54
|
+
* rather than double precision's +6e-17 — and the transmitted powers divide by it. The floor is
|
|
55
|
+
* for the device's number, and the rounding is what lets a test here see it.
|
|
56
|
+
*/
|
|
57
|
+
const cosD = Math.max(Math.cos(Math.fround(0.5 * Math.abs(Math.asin(sinO) - Math.asin(sinI)))), 1e-3);
|
|
58
|
+
let lp2 = 0;
|
|
59
|
+
let vp2 = 0;
|
|
60
|
+
let lpvp = 0;
|
|
61
|
+
for (let c = 0; c < 3; c++) {
|
|
62
|
+
const lp = toLight[c] - sinI * tangent[c];
|
|
63
|
+
const vp = toEye[c] - sinO * tangent[c];
|
|
64
|
+
lp2 += lp * lp;
|
|
65
|
+
vp2 += vp * vp;
|
|
66
|
+
lpvp += lp * vp;
|
|
67
|
+
}
|
|
68
|
+
const cosPhi = lpvp / Math.sqrt(lp2 * vp2 + 1e-4);
|
|
69
|
+
const cosHalfPhi = Math.sqrt(clamp(0.5 + 0.5 * cosPhi, 0, 1));
|
|
70
|
+
const beta = Math.max(roughness * roughness, HAIR_MIN_BETA);
|
|
71
|
+
const grow = sourceRadius / Math.max(2 * dist, 1e-3);
|
|
72
|
+
const x = sinI + sinO;
|
|
73
|
+
const r = hairLongitudinal(beta + grow, x + 2 * shift) *
|
|
74
|
+
0.25 *
|
|
75
|
+
cosHalfPhi *
|
|
76
|
+
hairFresnel(Math.sqrt(clamp(0.5 + 0.5 * dot(toLight, toEye), 0, 1)));
|
|
77
|
+
const a = 1 / (1.19 / cosD + 0.36 * cosD);
|
|
78
|
+
const h = cosHalfPhi * (1 + a * (0.6 - 0.8 * cosPhi));
|
|
79
|
+
const fTT = hairFresnel(cosD * Math.sqrt(clamp(1 - h * h, 0, 1)));
|
|
80
|
+
const tt = hairLongitudinal(0.5 * beta + grow, x - shift) *
|
|
81
|
+
Math.exp(-3.65 * cosPhi - 3.98) *
|
|
82
|
+
(1 - fTT) *
|
|
83
|
+
(1 - fTT) *
|
|
84
|
+
backlit;
|
|
85
|
+
const ttPower = (0.5 * Math.sqrt(clamp(1 - h * h * a * a, 0, 1))) / cosD;
|
|
86
|
+
const fTRT = hairFresnel(0.5 * cosD);
|
|
87
|
+
const trt = hairLongitudinal(2 * beta + grow, x - 3 * shift) *
|
|
88
|
+
Math.exp(17 * cosPhi - 16.78) *
|
|
89
|
+
(1 - fTRT) *
|
|
90
|
+
(1 - fTRT) *
|
|
91
|
+
fTRT;
|
|
92
|
+
const trtPower = 0.8 / cosD;
|
|
93
|
+
let across0 = toEye[0] - tangent[0] * sinO;
|
|
94
|
+
let across1 = toEye[1] - tangent[1] * sinO;
|
|
95
|
+
let across2 = toEye[2] - tangent[2] * sinO;
|
|
96
|
+
const acrossLength = Math.hypot(across0, across1, across2);
|
|
97
|
+
if (acrossLength > 1e-4) {
|
|
98
|
+
across0 /= acrossLength;
|
|
99
|
+
across1 /= acrossLength;
|
|
100
|
+
across2 /= acrossLength;
|
|
101
|
+
}
|
|
102
|
+
const wrapped = clamp((across0 * toLight[0] +
|
|
103
|
+
across1 * toLight[1] +
|
|
104
|
+
across2 * toLight[2] +
|
|
105
|
+
1) /
|
|
106
|
+
4, 0, 1);
|
|
107
|
+
const shape = wrapped + (1 - Math.abs(sinI) - wrapped) * KAJIYA_SHARE;
|
|
108
|
+
out[HAIR_OUT.r] = Math.PI * r;
|
|
109
|
+
for (let c = 0; c < 3; c++) {
|
|
110
|
+
/*
|
|
111
|
+
* Above zero, so the device's `pow` never forms the logarithm of zero: an infinity in the
|
|
112
|
+
* middle of an expression is something a shader compiler may assume never happens. Nothing in
|
|
113
|
+
* double precision can show the difference, so no test here does.
|
|
114
|
+
*/
|
|
115
|
+
const colour = Math.max(fibre[c], 1e-4);
|
|
116
|
+
out[HAIR_OUT.tt + c] = Math.PI * tt * colour ** ttPower;
|
|
117
|
+
out[HAIR_OUT.trt + c] = Math.PI * trt * colour ** trtPower;
|
|
118
|
+
out[HAIR_OUT.scatter + c] = Math.sqrt(fibre[c]) * scatter * shape;
|
|
119
|
+
}
|
|
120
|
+
}
|