auto-git:

[add] src/rendering/quantized-environment-blend-cache.ts
This commit is contained in:
2026-04-22 13:44:26 +02:00
parent 899ca9170b
commit 0e0c327d64

View File

@@ -0,0 +1,505 @@
import {
EquirectangularReflectionMapping,
HalfFloatType,
LinearFilter,
LinearSRGBColorSpace,
Mesh,
NoToneMapping,
OrthographicCamera,
PlaneGeometry,
PMREMGenerator,
Scene,
ShaderMaterial,
SRGBColorSpace,
Texture,
WebGLRenderTarget,
WebGLRenderer
} from "three";
import type { WorldEnvironmentBlendTextureResolver } from "./world-background-renderer";
const DEFAULT_QUANTIZED_BLEND_BUCKET_COUNT = 8;
const DEFAULT_BLEND_RENDER_TARGET_WIDTH = 512;
const DEFAULT_BLEND_RENDER_TARGET_HEIGHT = 256;
const BLEND_VERTEX_SHADER = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const BLEND_FRAGMENT_SHADER = `
uniform sampler2D uBaseTexture;
uniform sampler2D uOverlayTexture;
uniform float uBlendAmount;
uniform float uBaseTextureIsSrgb;
uniform float uOverlayTextureIsSrgb;
varying vec2 vUv;
vec3 srgbToLinear(vec3 color) {
bvec3 cutoff = lessThanEqual(color, vec3(0.04045));
vec3 lower = color / 12.92;
vec3 higher = pow((color + 0.055) / 1.055, vec3(2.4));
return mix(higher, lower, vec3(cutoff));
}
vec3 decodeTextureColor(sampler2D map, vec2 uv, float isSrgb) {
vec3 color = texture2D(map, uv).rgb;
return mix(color, srgbToLinear(color), isSrgb);
}
void main() {
vec3 baseColor = decodeTextureColor(uBaseTexture, vUv, uBaseTextureIsSrgb);
vec3 overlayColor = decodeTextureColor(
uOverlayTexture,
vUv,
uOverlayTextureIsSrgb
);
gl_FragColor = vec4(
mix(baseColor, overlayColor, clamp(uBlendAmount, 0.0, 1.0)),
1.0
);
}
`;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function defaultBuildScheduler(callback: () => void) {
if (
typeof window !== "undefined" &&
typeof window.requestAnimationFrame === "function"
) {
window.requestAnimationFrame(() => {
callback();
});
return;
}
setTimeout(callback, 0);
}
function getTextureCacheKey(texture: Texture): string {
return texture.uuid;
}
function getOrderedPairCacheKey(
baseTexture: Texture,
overlayTexture: Texture
): string {
return `${getTextureCacheKey(baseTexture)}->${getTextureCacheKey(overlayTexture)}`;
}
function isSrgbTexture(texture: Texture): boolean {
return texture.colorSpace === SRGBColorSpace;
}
export interface CachedEnvironmentBlendTexture {
texture: Texture;
dispose: () => void;
}
export interface QuantizedEnvironmentBlendCacheOptions {
bucketCount?: number;
buildBlendTexture: (
baseTexture: Texture,
overlayTexture: Texture,
blendAmount: number
) => CachedEnvironmentBlendTexture;
disposeBuildResources?: () => void;
onTextureReady?: () => void;
scheduleBuild?: (callback: () => void) => void;
}
interface QueuedEnvironmentBlendBuild {
baseTexture: Texture;
overlayTexture: Texture;
bucketIndex: number;
blendAmount: number;
key: string;
pairKey: string;
}
export function quantizeEnvironmentBlendBucket(
blendAmount: number,
bucketCount: number = DEFAULT_QUANTIZED_BLEND_BUCKET_COUNT
): number {
const safeBucketCount = Math.max(1, Math.floor(bucketCount));
return Math.round(clamp(blendAmount, 0, 1) * safeBucketCount);
}
export function getQuantizedEnvironmentBlendAmount(
bucketIndex: number,
bucketCount: number = DEFAULT_QUANTIZED_BLEND_BUCKET_COUNT
): number {
const safeBucketCount = Math.max(1, Math.floor(bucketCount));
return clamp(bucketIndex / safeBucketCount, 0, 1);
}
export function createQuantizedEnvironmentBlendCacheKey(
baseTexture: Texture,
overlayTexture: Texture,
bucketIndex: number
): string {
return `${getOrderedPairCacheKey(baseTexture, overlayTexture)}@${bucketIndex}`;
}
export class QuantizedEnvironmentBlendCache
implements WorldEnvironmentBlendTextureResolver
{
private readonly bucketCount: number;
private readonly buildBlendTexture: QuantizedEnvironmentBlendCacheOptions["buildBlendTexture"];
private readonly disposeBuildResources: (() => void) | undefined;
private readonly onTextureReady: (() => void) | undefined;
private readonly scheduleBuildCallback: (callback: () => void) => void;
private readonly entries = new Map<string, CachedEnvironmentBlendTexture>();
private readonly pairEntries = new Map<
string,
Map<number, CachedEnvironmentBlendTexture>
>();
private readonly pendingKeys = new Set<string>();
private readonly failedKeys = new Set<string>();
private pendingBuilds: QueuedEnvironmentBlendBuild[] = [];
private buildScheduled = false;
private disposed = false;
constructor(options: QuantizedEnvironmentBlendCacheOptions) {
this.bucketCount = Math.max(
1,
Math.floor(
options.bucketCount ?? DEFAULT_QUANTIZED_BLEND_BUCKET_COUNT
)
);
this.buildBlendTexture = options.buildBlendTexture;
this.disposeBuildResources = options.disposeBuildResources;
this.onTextureReady = options.onTextureReady;
this.scheduleBuildCallback = options.scheduleBuild ?? defaultBuildScheduler;
}
resolveBlendTexture(
baseTexture: Texture,
overlayTexture: Texture,
blendAmount: number
): Texture | null {
const bucketIndex = quantizeEnvironmentBlendBucket(
blendAmount,
this.bucketCount
);
if (bucketIndex <= 0) {
return baseTexture;
}
if (bucketIndex >= this.bucketCount) {
return overlayTexture;
}
const key = createQuantizedEnvironmentBlendCacheKey(
baseTexture,
overlayTexture,
bucketIndex
);
const cachedEntry = this.entries.get(key);
if (cachedEntry !== undefined) {
return cachedEntry.texture;
}
if (!this.failedKeys.has(key)) {
this.queueBuild({
baseTexture,
overlayTexture,
bucketIndex,
blendAmount: getQuantizedEnvironmentBlendAmount(
bucketIndex,
this.bucketCount
),
key,
pairKey: getOrderedPairCacheKey(baseTexture, overlayTexture)
});
}
return this.findNearestCachedTexture(
getOrderedPairCacheKey(baseTexture, overlayTexture),
bucketIndex
);
}
clear() {
this.pendingBuilds = [];
this.pendingKeys.clear();
this.failedKeys.clear();
for (const entry of this.entries.values()) {
entry.dispose();
}
this.entries.clear();
this.pairEntries.clear();
}
dispose() {
if (this.disposed) {
return;
}
this.disposed = true;
this.clear();
this.disposeBuildResources?.();
}
private queueBuild(request: QueuedEnvironmentBlendBuild) {
if (
this.disposed ||
this.entries.has(request.key) ||
this.pendingKeys.has(request.key)
) {
return;
}
this.pendingKeys.add(request.key);
this.pendingBuilds.push(request);
if (this.buildScheduled) {
return;
}
this.buildScheduled = true;
this.scheduleBuildCallback(() => {
this.buildScheduled = false;
this.processNextBuild();
});
}
private processNextBuild() {
if (this.disposed) {
return;
}
const nextBuild = this.pendingBuilds.shift();
if (nextBuild === undefined) {
return;
}
this.pendingKeys.delete(nextBuild.key);
if (this.entries.has(nextBuild.key) || this.failedKeys.has(nextBuild.key)) {
this.schedulePendingBuilds();
return;
}
try {
const builtEntry = this.buildBlendTexture(
nextBuild.baseTexture,
nextBuild.overlayTexture,
nextBuild.blendAmount
);
this.entries.set(nextBuild.key, builtEntry);
let pairEntries = this.pairEntries.get(nextBuild.pairKey);
if (pairEntries === undefined) {
pairEntries = new Map<number, CachedEnvironmentBlendTexture>();
this.pairEntries.set(nextBuild.pairKey, pairEntries);
}
pairEntries.set(nextBuild.bucketIndex, builtEntry);
this.onTextureReady?.();
} catch (error) {
this.failedKeys.add(nextBuild.key);
console.warn(
"Failed to build quantized environment blend texture.",
error
);
}
this.schedulePendingBuilds();
}
private schedulePendingBuilds() {
if (this.disposed || this.buildScheduled || this.pendingBuilds.length === 0) {
return;
}
this.buildScheduled = true;
this.scheduleBuildCallback(() => {
this.buildScheduled = false;
this.processNextBuild();
});
}
private findNearestCachedTexture(
pairKey: string,
bucketIndex: number
): Texture | null {
const pairEntries = this.pairEntries.get(pairKey);
if (pairEntries === undefined || pairEntries.size === 0) {
return null;
}
let nearestTexture: Texture | null = null;
let nearestDistance = Number.POSITIVE_INFINITY;
for (const [availableBucketIndex, entry] of pairEntries.entries()) {
const distance = Math.abs(availableBucketIndex - bucketIndex);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestTexture = entry.texture;
}
}
return nearestTexture;
}
}
class RendererEnvironmentBlendTextureBuilder {
private readonly pmremGenerator: PMREMGenerator;
private readonly blendScene = new Scene();
private readonly blendCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
private readonly blendMaterial = new ShaderMaterial({
uniforms: {
uBaseTexture: {
value: null
},
uOverlayTexture: {
value: null
},
uBlendAmount: {
value: 0
},
uBaseTextureIsSrgb: {
value: 1
},
uOverlayTextureIsSrgb: {
value: 1
}
},
vertexShader: BLEND_VERTEX_SHADER,
fragmentShader: BLEND_FRAGMENT_SHADER,
depthTest: false,
depthWrite: false,
fog: false
});
private readonly blendMesh = new Mesh(
new PlaneGeometry(2, 2),
this.blendMaterial
);
private readonly scratchTarget: WebGLRenderTarget;
constructor(
private readonly renderer: WebGLRenderer,
options: {
targetWidth?: number;
targetHeight?: number;
} = {}
) {
this.pmremGenerator = new PMREMGenerator(renderer);
this.pmremGenerator.compileEquirectangularShader();
this.blendMesh.frustumCulled = false;
this.blendScene.add(this.blendMesh);
this.scratchTarget = new WebGLRenderTarget(
options.targetWidth ?? DEFAULT_BLEND_RENDER_TARGET_WIDTH,
options.targetHeight ?? DEFAULT_BLEND_RENDER_TARGET_HEIGHT,
{
depthBuffer: false,
stencilBuffer: false,
magFilter: LinearFilter,
minFilter: LinearFilter,
type: HalfFloatType
}
);
this.scratchTarget.texture.colorSpace = LinearSRGBColorSpace;
this.scratchTarget.texture.mapping = EquirectangularReflectionMapping;
this.scratchTarget.texture.generateMipmaps = false;
}
build(
baseTexture: Texture,
overlayTexture: Texture,
blendAmount: number
): CachedEnvironmentBlendTexture {
const previousRenderTarget = this.renderer.getRenderTarget();
const previousAutoClear = this.renderer.autoClear;
const previousXrEnabled = this.renderer.xr.enabled;
const previousToneMapping = this.renderer.toneMapping;
const previousOutputColorSpace = this.renderer.outputColorSpace;
this.renderer.xr.enabled = false;
this.renderer.autoClear = true;
this.renderer.toneMapping = NoToneMapping;
this.renderer.outputColorSpace = LinearSRGBColorSpace;
this.blendMaterial.uniforms.uBaseTexture.value = baseTexture;
this.blendMaterial.uniforms.uOverlayTexture.value = overlayTexture;
this.blendMaterial.uniforms.uBlendAmount.value = blendAmount;
this.blendMaterial.uniforms.uBaseTextureIsSrgb.value = isSrgbTexture(
baseTexture
)
? 1
: 0;
this.blendMaterial.uniforms.uOverlayTextureIsSrgb.value = isSrgbTexture(
overlayTexture
)
? 1
: 0;
this.renderer.setRenderTarget(this.scratchTarget);
this.renderer.clear();
this.renderer.render(this.blendScene, this.blendCamera);
const pmremTarget = this.pmremGenerator.fromEquirectangular(
this.scratchTarget.texture
);
this.renderer.setRenderTarget(previousRenderTarget);
this.renderer.autoClear = previousAutoClear;
this.renderer.xr.enabled = previousXrEnabled;
this.renderer.toneMapping = previousToneMapping;
this.renderer.outputColorSpace = previousOutputColorSpace;
return {
texture: pmremTarget.texture,
dispose: () => {
pmremTarget.dispose();
}
};
}
dispose() {
this.scratchTarget.dispose();
this.blendMesh.geometry.dispose();
this.blendMaterial.dispose();
this.pmremGenerator.dispose();
}
}
export function createRendererQuantizedEnvironmentBlendCache(
renderer: WebGLRenderer,
options: Omit<
QuantizedEnvironmentBlendCacheOptions,
"buildBlendTexture" | "disposeBuildResources"
> & {
targetWidth?: number;
targetHeight?: number;
} = {}
): QuantizedEnvironmentBlendCache {
const builder = new RendererEnvironmentBlendTextureBuilder(renderer, {
targetWidth: options.targetWidth,
targetHeight: options.targetHeight
});
return new QuantizedEnvironmentBlendCache({
...options,
buildBlendTexture: (baseTexture, overlayTexture, blendAmount) =>
builder.build(baseTexture, overlayTexture, blendAmount),
disposeBuildResources: () => {
builder.dispose();
}
});
}