Skip to content

Commit 2baa76a

Browse files
committed
feat(soba): add NgtsMeshPortalMaterial (use pmndrs/vanilla)
This needs `@pmndrs/[email protected]`
1 parent 2f2a551 commit 2baa76a

File tree

5 files changed

+489
-4
lines changed

5 files changed

+489
-4
lines changed
Binary file not shown.

libs/soba/materials/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './lib/custom-shader-material';
22
export * from './lib/mesh-distort-material';
3+
export * from './lib/mesh-portal-material';
34
export * from './lib/mesh-reflector-material';
45
export * from './lib/mesh-refraction-material';
56
export * from './lib/mesh-transmission-material';
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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

Comments
 (0)