|
| 1 | +import { NgTemplateOutlet } from '@angular/common'; |
| 2 | +import { |
| 3 | + afterNextRender, |
| 4 | + ChangeDetectionStrategy, |
| 5 | + Component, |
| 6 | + computed, |
| 7 | + contentChild, |
| 8 | + CUSTOM_ELEMENTS_SCHEMA, |
| 9 | + Directive, |
| 10 | + effect, |
| 11 | + ElementRef, |
| 12 | + inject, |
| 13 | + Injector, |
| 14 | + input, |
| 15 | + signal, |
| 16 | + TemplateRef, |
| 17 | + viewChild, |
| 18 | +} from '@angular/core'; |
| 19 | +import { |
| 20 | + extend, |
| 21 | + getLocalState, |
| 22 | + injectBeforeRender, |
| 23 | + injectStore, |
| 24 | + NgtAttachable, |
| 25 | + NgtComputeFunction, |
| 26 | + NgtShaderMaterial, |
| 27 | + omit, |
| 28 | + pick, |
| 29 | +} from 'angular-three'; |
| 30 | +import { getVersion, injectFBO, injectIntersect } from 'angular-three-soba/misc'; |
| 31 | +import { NgtsRenderTexture, NgtsRenderTextureContent } from 'angular-three-soba/staging'; |
| 32 | +import { |
| 33 | + MeshPortalMaterial, |
| 34 | + meshPortalMaterialApplySDF, |
| 35 | + NgtMeshPortalMaterial, |
| 36 | +} from 'angular-three-soba/vanilla-exports'; |
| 37 | +import { mergeInputs } from 'ngxtension/inject-inputs'; |
| 38 | +import { Mesh, Scene, ShaderMaterial } from 'three'; |
| 39 | +import { FullScreenQuad } from 'three-stdlib'; |
| 40 | + |
| 41 | +/** |
| 42 | + * This directive is used inside of the render texture, hence has access to the render texture store (a portal store) |
| 43 | + */ |
| 44 | +@Directive({ selector: 'ngts-manage-portal-scene', standalone: true }) |
| 45 | +export class ManagePortalScene { |
| 46 | + events = input<boolean>(); |
| 47 | + rootScene = input.required<Scene>(); |
| 48 | + material = input.required<NgtMeshPortalMaterial>(); |
| 49 | + priority = input.required<number>(); |
| 50 | + worldUnits = input.required<boolean>(); |
| 51 | + |
| 52 | + constructor() { |
| 53 | + const injector = inject(Injector); |
| 54 | + const renderTextureStore = injectStore(); |
| 55 | + const portalScene = renderTextureStore.select('scene'); |
| 56 | + const portalSetEvents = renderTextureStore.select('setEvents'); |
| 57 | + |
| 58 | + const buffer1 = injectFBO(); |
| 59 | + const buffer2 = injectFBO(); |
| 60 | + |
| 61 | + const fullScreenQuad = computed(() => { |
| 62 | + // This fullscreen-quad is used to blend the two textures |
| 63 | + const blend = { value: 0 }; |
| 64 | + const quad = new FullScreenQuad( |
| 65 | + new ShaderMaterial({ |
| 66 | + uniforms: { |
| 67 | + a: { value: buffer1().texture }, |
| 68 | + b: { value: buffer2().texture }, |
| 69 | + blend, |
| 70 | + }, |
| 71 | + vertexShader: /*glsl*/ ` |
| 72 | + varying vec2 vUv; |
| 73 | + void main() { |
| 74 | + vUv = uv; |
| 75 | + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); |
| 76 | + }`, |
| 77 | + fragmentShader: /*glsl*/ ` |
| 78 | + uniform sampler2D a; |
| 79 | + uniform sampler2D b; |
| 80 | + uniform float blend; |
| 81 | + varying vec2 vUv; |
| 82 | + #include <packing> |
| 83 | + void main() { |
| 84 | + vec4 ta = texture2D(a, vUv); |
| 85 | + vec4 tb = texture2D(b, vUv); |
| 86 | + gl_FragColor = mix(tb, ta, blend); |
| 87 | + #include <tonemapping_fragment> |
| 88 | + #include <${getVersion() >= 154 ? 'colorspace_fragment' : 'encodings_fragment'}> |
| 89 | + }`, |
| 90 | + }), |
| 91 | + ); |
| 92 | + return [quad, blend] as const; |
| 93 | + }); |
| 94 | + |
| 95 | + effect(() => { |
| 96 | + const [events, setEvents] = [this.events(), portalSetEvents()]; |
| 97 | + if (!events) return; |
| 98 | + setEvents({ enabled: events }); |
| 99 | + }); |
| 100 | + |
| 101 | + afterNextRender(() => { |
| 102 | + portalScene().matrixAutoUpdate = false; |
| 103 | + |
| 104 | + // we start the before render in afterNextRender because we need the priority input to be resolved |
| 105 | + injectBeforeRender( |
| 106 | + ({ gl, camera }) => { |
| 107 | + const material = this.material(); |
| 108 | + |
| 109 | + const localState = getLocalState(material); |
| 110 | + if (!localState) return; |
| 111 | + |
| 112 | + const parent = localState.parent(); |
| 113 | + if (!parent) return; |
| 114 | + |
| 115 | + const materialBlend = 'blend' in material && typeof material.blend === 'number' ? material.blend : 0; |
| 116 | + const [worldUnits, priority, rootScene, scene, [quad, blend]] = [ |
| 117 | + this.worldUnits(), |
| 118 | + this.priority(), |
| 119 | + this.rootScene(), |
| 120 | + portalScene(), |
| 121 | + fullScreenQuad(), |
| 122 | + ]; |
| 123 | + // Move portal contents along with the parent if worldUnits is true |
| 124 | + if (!worldUnits) { |
| 125 | + // If the portal renders exclusively the original scene needs to be updated |
| 126 | + if (priority && materialBlend === 1) parent.updateWorldMatrix(true, false); |
| 127 | + scene.matrixWorld.copy(parent.matrixWorld); |
| 128 | + } else { |
| 129 | + scene.matrixWorld.identity(); |
| 130 | + } |
| 131 | + |
| 132 | + // This bit is only necessary if the portal is blended, now it has a render-priority |
| 133 | + // and will take over the render loop |
| 134 | + if (priority) { |
| 135 | + if (materialBlend > 0 && materialBlend < 1) { |
| 136 | + // If blend is ongoing (> 0 and < 1) then we need to render both the root scene |
| 137 | + // and the portal scene, both will then be mixed in the quad from above |
| 138 | + blend.value = materialBlend; |
| 139 | + gl.setRenderTarget(buffer1()); |
| 140 | + gl.render(scene, camera); |
| 141 | + gl.setRenderTarget(buffer2()); |
| 142 | + gl.render(rootScene, camera); |
| 143 | + gl.setRenderTarget(null); |
| 144 | + quad.render(gl); |
| 145 | + } else if (materialBlend === 1) { |
| 146 | + // However if blend is 1 we only need to render the portal scene |
| 147 | + gl.render(scene, camera); |
| 148 | + } |
| 149 | + } |
| 150 | + }, |
| 151 | + { injector, priority: this.priority() }, |
| 152 | + ); |
| 153 | + }); |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +export interface NgtsMeshPortalMaterialOptions extends Partial<NgtShaderMaterial> { |
| 158 | + /** Mix the portals own scene with the world scene, 0 = world scene render, |
| 159 | + * 0.5 = both scenes render, 1 = portal scene renders, defaults to 0 */ |
| 160 | + blend: number; |
| 161 | + /** Edge fade blur, 0 = no blur (default) */ |
| 162 | + blur: number; |
| 163 | + /** SDF resolution, the smaller the faster is the start-up time (default: 512) */ |
| 164 | + resolution: number; |
| 165 | + /** By default portals use relative coordinates, contents are affects by the local matrix transform */ |
| 166 | + worldUnits: boolean; |
| 167 | + /** Optional event priority, defaults to 0 */ |
| 168 | + eventPriority: number; |
| 169 | + /** Optional render priority, defaults to 0 */ |
| 170 | + renderPriority: number; |
| 171 | + /** Optionally diable events inside the portal, defaults to false */ |
| 172 | + events: boolean; |
| 173 | +} |
| 174 | + |
| 175 | +const defaultOptions: NgtsMeshPortalMaterialOptions = { |
| 176 | + blend: 0.5, |
| 177 | + blur: 0, |
| 178 | + resolution: 512, |
| 179 | + worldUnits: false, |
| 180 | + eventPriority: 0, |
| 181 | + renderPriority: 0, |
| 182 | + events: false, |
| 183 | +}; |
| 184 | + |
| 185 | +@Component({ |
| 186 | + selector: 'ngts-mesh-portal-material', |
| 187 | + standalone: true, |
| 188 | + template: ` |
| 189 | + <ngt-mesh-portal-material |
| 190 | + #material |
| 191 | + [attach]="attach()" |
| 192 | + [blur]="blur()" |
| 193 | + [blend]="0" |
| 194 | + [resolution]="materialResolution()" |
| 195 | + [parameters]="parameters()" |
| 196 | + > |
| 197 | + <ngts-render-texture |
| 198 | + [options]="{ |
| 199 | + frames: renderTextureFrames(), |
| 200 | + eventPriority: eventPriority(), |
| 201 | + renderPriority: renderPriority(), |
| 202 | + compute: renderTextureCompute(), |
| 203 | + }" |
| 204 | + > |
| 205 | + <ng-template renderTextureContent let-injector="injector"> |
| 206 | + <ng-container *ngTemplateOutlet="content(); injector: injector" /> |
| 207 | + <ngts-manage-portal-scene |
| 208 | + [events]="events()" |
| 209 | + [rootScene]="rootScene()" |
| 210 | + [priority]="priority()" |
| 211 | + [material]="material" |
| 212 | + [worldUnits]="worldUnits()" |
| 213 | + /> |
| 214 | + </ng-template> |
| 215 | + </ngts-render-texture> |
| 216 | + </ngt-mesh-portal-material> |
| 217 | + `, |
| 218 | + schemas: [CUSTOM_ELEMENTS_SCHEMA], |
| 219 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 220 | + imports: [NgtsRenderTexture, NgtsRenderTextureContent, ManagePortalScene, NgTemplateOutlet], |
| 221 | +}) |
| 222 | +export class NgtsMeshPortalMaterial { |
| 223 | + attach = input<NgtAttachable>('material'); |
| 224 | + options = input(defaultOptions, { transform: mergeInputs(defaultOptions) }); |
| 225 | + parameters = omit(this.options, ['blur', 'resolution', 'worldUnits', 'eventPriority', 'renderPriority', 'events']); |
| 226 | + |
| 227 | + blur = pick(this.options, 'blur'); |
| 228 | + eventPriority = pick(this.options, 'eventPriority'); |
| 229 | + renderPriority = pick(this.options, 'renderPriority'); |
| 230 | + events = pick(this.options, 'events'); |
| 231 | + worldUnits = pick(this.options, 'worldUnits'); |
| 232 | + |
| 233 | + materialRef = viewChild.required<ElementRef<InstanceType<typeof MeshPortalMaterial>>>('material'); |
| 234 | + content = contentChild.required(TemplateRef); |
| 235 | + |
| 236 | + private store = injectStore(); |
| 237 | + private size = this.store.select('size'); |
| 238 | + private viewport = this.store.select('viewport'); |
| 239 | + private gl = this.store.select('gl'); |
| 240 | + private setEvents = this.store.select('setEvents'); |
| 241 | + rootScene = this.store.select('scene'); |
| 242 | + |
| 243 | + materialResolution = computed(() => [ |
| 244 | + this.size().width * this.viewport().dpr, |
| 245 | + this.size().height * this.viewport().dpr, |
| 246 | + ]); |
| 247 | + private resolution = pick(this.options, 'resolution'); |
| 248 | + |
| 249 | + private parent = signal<Mesh | null>(null); |
| 250 | + private visible = injectIntersect(this.parent, { source: signal(true) }); |
| 251 | + |
| 252 | + renderTextureFrames = computed(() => (this.visible() ? Infinity : 0)); |
| 253 | + renderTextureCompute = computed(() => { |
| 254 | + const [parent, material] = [this.parent(), this.materialRef().nativeElement]; |
| 255 | + |
| 256 | + const computeFn: (...args: Parameters<NgtComputeFunction>) => false | undefined = (event, state) => { |
| 257 | + if (!parent) return false; |
| 258 | + state.snapshot.pointer.set( |
| 259 | + (event.offsetX / state.snapshot.size.width) * 2 - 1, |
| 260 | + -(event.offsetY / state.snapshot.size.height) * 2 + 1, |
| 261 | + ); |
| 262 | + state.snapshot.raycaster.setFromCamera(state.snapshot.pointer, state.snapshot.camera); |
| 263 | + |
| 264 | + if ('blend' in material && material.blend === 0) { |
| 265 | + // We run a quick check against the parent, if it isn't hit there's no need to raycast at all |
| 266 | + const [intersection] = state.snapshot.raycaster.intersectObject(parent); |
| 267 | + if (!intersection) { |
| 268 | + // Cancel out the raycast camera if the parent mesh isn't hit |
| 269 | + Object.assign(state.snapshot.raycaster, { camera: undefined }); |
| 270 | + return false; |
| 271 | + } |
| 272 | + } |
| 273 | + |
| 274 | + return; |
| 275 | + }; |
| 276 | + |
| 277 | + return computeFn; |
| 278 | + }); |
| 279 | + |
| 280 | + priority = signal(0); |
| 281 | + |
| 282 | + constructor() { |
| 283 | + extend({ MeshPortalMaterial }); |
| 284 | + |
| 285 | + afterNextRender(() => { |
| 286 | + const material = this.materialRef().nativeElement; |
| 287 | + |
| 288 | + const localState = getLocalState(material); |
| 289 | + if (!localState) return; |
| 290 | + |
| 291 | + const materialParent = localState.parent(); |
| 292 | + if (!materialParent || !(materialParent instanceof Mesh)) return; |
| 293 | + |
| 294 | + // Since the ref above is not tied to a mesh directly (we're inside a material), |
| 295 | + // it has to be tied to the parent mesh here |
| 296 | + this.parent.set(materialParent); |
| 297 | + }); |
| 298 | + |
| 299 | + effect(() => { |
| 300 | + const events = this.events(); |
| 301 | + if (!events) return; |
| 302 | + |
| 303 | + const setEvents = this.setEvents(); |
| 304 | + setEvents({ enabled: !events }); |
| 305 | + }); |
| 306 | + |
| 307 | + // React.useEffect(() => { |
| 308 | + // if (events !== undefined) setEvents({ enabled: !events }) |
| 309 | + // }, [events]) |
| 310 | + effect(() => { |
| 311 | + const [material, parent] = [this.materialRef().nativeElement, this.parent()]; |
| 312 | + if (!parent) return; |
| 313 | + |
| 314 | + const [resolution, blur, gl] = [this.resolution(), this.blur(), this.gl()]; |
| 315 | + |
| 316 | + // apply the SDF mask once |
| 317 | + if (blur && material.sdf == null) { |
| 318 | + meshPortalMaterialApplySDF(parent, resolution, gl); |
| 319 | + } |
| 320 | + }); |
| 321 | + |
| 322 | + injectBeforeRender(() => { |
| 323 | + const material = this.materialRef().nativeElement; |
| 324 | + const priority = |
| 325 | + 'blend' in material && typeof material.blend === 'number' && material.blend > 0 |
| 326 | + ? Math.max(1, this.renderPriority()) |
| 327 | + : 0; |
| 328 | + |
| 329 | + // If blend is > 0 then the portal is being entered, the render-priority must change |
| 330 | + if (this.priority() !== priority) { |
| 331 | + this.priority.set(priority); |
| 332 | + } |
| 333 | + }); |
| 334 | + } |
| 335 | +} |
0 commit comments