import * as THREE from "three"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js"; import type { GpuRenderTargetAllocation, GpuRenderTargetReporter, SceneResourceBag, } from "@/scene/resources"; export interface PostProcessor extends GpuRenderTargetReporter { resize(width: number, height: number, pixelRatio: number): void; render(scene: unknown, camera: unknown): void; dispose(): void; } interface RenderTargetLike { width: number; height: number; samples?: number; depthBuffer?: boolean; } interface ComposerLike { renderTarget1?: RenderTargetLike; renderTarget2?: RenderTargetLike; addPass(pass: unknown): void; setPixelRatio(pixelRatio: number): void; setSize(width: number, height: number): void; render(): void; dispose(): void; } interface RenderPassLike { scene: unknown; camera: unknown; dispose(): void; } interface BloomPassLike { renderTargetBright?: RenderTargetLike; renderTargetsHorizontal?: readonly RenderTargetLike[]; renderTargetsVertical?: readonly RenderTargetLike[]; dispose(): void; } export interface PostProcessingFactories { createComposer(renderer: unknown): ComposerLike; createRenderPass(scene: unknown, camera: unknown): RenderPassLike; createBloomPass(): BloomPassLike; } const productionFactories: PostProcessingFactories = { createComposer: (renderer) => new EffectComposer(renderer), createRenderPass: (scene, camera) => new RenderPass(scene, camera), createBloomPass: () => new UnrealBloomPass(new THREE.Vector2(1, 1), 0.46, 0.32, 0.84), }; export function createPostProcessor( resources: SceneResourceBag, renderer: unknown, factories: PostProcessingFactories = productionFactories, ): PostProcessor { const composer = factories.createComposer(renderer); const renderPass = factories.createRenderPass( new THREE.Scene(), new THREE.Camera(), ); const bloomPass = factories.createBloomPass(); composer.addPass(renderPass); composer.addPass(bloomPass); let disposed = false; const processor: PostProcessor = { gpuRenderTargets() { return collectRenderTargetAllocations(composer, bloomPass); }, resize(width, height, pixelRatio) { if (disposed) return; composer.setPixelRatio(clamp(pixelRatio, 0.5, 2)); composer.setSize( Math.max(1, Math.floor(finiteOr(width, 1))), Math.max(1, Math.floor(finiteOr(height, 1))), ); }, render(scene, camera) { if (disposed) return; renderPass.scene = scene; renderPass.camera = camera; composer.render(); }, dispose() { if (disposed) return; disposed = true; renderPass.dispose(); bloomPass.dispose(); composer.dispose(); }, }; return resources.own("pass", processor); } function collectRenderTargetAllocations( composer: ComposerLike, bloomPass: BloomPassLike, ): readonly GpuRenderTargetAllocation[] { const candidates: ReadonlyArray< readonly [string, RenderTargetLike | undefined] > = [ ["composer-target-1", composer.renderTarget1], ["composer-target-2", composer.renderTarget2], ["bloom-bright", bloomPass.renderTargetBright], ...(bloomPass.renderTargetsHorizontal ?? []).map( (target, index) => [`bloom-horizontal-${index}`, target] as const, ), ...(bloomPass.renderTargetsVertical ?? []).map( (target, index) => [`bloom-vertical-${index}`, target] as const, ), ]; const seen = new Set(); return candidates.flatMap(([id, target]) => { if (target === undefined || seen.has(target)) return []; seen.add(target); return [ { id, width: Math.max(1, Math.floor(finiteOr(target.width, 1))), height: Math.max(1, Math.floor(finiteOr(target.height, 1))), bytesPerPixel: 4, attachments: target.depthBuffer === false ? 1 : 2, samples: Math.max(1, Math.floor(finiteOr(target.samples ?? 1, 1))), }, ]; }); } function finiteOr(value: number, fallback: number): number { return Number.isFinite(value) ? value : fallback; } function clamp(value: number, minimum: number, maximum: number): number { return THREE.MathUtils.clamp(finiteOr(value, minimum), minimum, maximum); }