Skip to content

Commit 60c41e0

Browse files
mvaligurskyMartin Valigursky
andauthored
Expose compose shader customization along with the example (#7989)
* Expose compose shader customization along with the example * recreate shader when custom chunks change --------- Co-authored-by: Martin Valigursky <[email protected]>
1 parent df90c97 commit 60c41e0

File tree

9 files changed

+331
-7
lines changed

9 files changed

+331
-7
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as pc from 'playcanvas';
2+
3+
/**
4+
* @param {import('../../app/components/Example.mjs').ControlOptions} options - The options.
5+
* @returns {JSX.Element} The returned JSX Element.
6+
*/
7+
export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
8+
const { BindingTwoWay, SelectInput, LabelGroup, SliderInput } = ReactPCUI;
9+
return fragment(
10+
jsx(
11+
LabelGroup,
12+
{ text: 'Tonemap' },
13+
jsx(SelectInput, {
14+
binding: new BindingTwoWay(),
15+
link: { observer, path: 'data.sceneTonemapping' },
16+
type: 'number',
17+
options: [
18+
{ v: pc.TONEMAP_LINEAR, t: 'LINEAR' },
19+
{ v: pc.TONEMAP_FILMIC, t: 'FILMIC' },
20+
{ v: pc.TONEMAP_HEJL, t: 'HEJL' },
21+
{ v: pc.TONEMAP_ACES, t: 'ACES' },
22+
{ v: pc.TONEMAP_ACES2, t: 'ACES2' },
23+
{ v: pc.TONEMAP_NEUTRAL, t: 'NEUTRAL' }
24+
]
25+
})
26+
),
27+
jsx(
28+
LabelGroup,
29+
{ text: 'Pixel Size' },
30+
jsx(SliderInput, {
31+
binding: new BindingTwoWay(),
32+
link: { observer, path: 'data.pixelSize' },
33+
min: 8,
34+
max: 20,
35+
precision: 0
36+
})
37+
),
38+
jsx(
39+
LabelGroup,
40+
{ text: 'Intensity' },
41+
jsx(SliderInput, {
42+
binding: new BindingTwoWay(),
43+
link: { observer, path: 'data.pixelationIntensity' },
44+
min: 0,
45+
max: 1,
46+
precision: 2
47+
})
48+
)
49+
);
50+
};
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// @config DESCRIPTION This example shows how to customize the final compose pass by injecting a simple pixelation post-effect. Useful if no additional render passes are needed. Changes are applied globally to all CameraFrames.
2+
import { data } from 'examples/observer';
3+
import { deviceType, rootPath } from 'examples/utils';
4+
import * as pc from 'playcanvas';
5+
6+
const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
7+
window.focus();
8+
9+
const assets = {
10+
orbit: new pc.Asset('script', 'script', { url: `${rootPath}/static/scripts/camera/orbit-camera.js` }),
11+
apartment: new pc.Asset('apartment', 'container', { url: `${rootPath}/static/assets/models/apartment.glb` }),
12+
love: new pc.Asset('love', 'container', { url: `${rootPath}/static/assets/models/love.glb` }),
13+
helipad: new pc.Asset(
14+
'helipad-env-atlas',
15+
'texture',
16+
{ url: `${rootPath}/static/assets/cubemaps/helipad-env-atlas.png` },
17+
{ type: pc.TEXTURETYPE_RGBP, mipmaps: false }
18+
)
19+
};
20+
21+
const gfxOptions = {
22+
deviceTypes: [deviceType],
23+
glslangUrl: `${rootPath}/static/lib/glslang/glslang.js`,
24+
twgslUrl: `${rootPath}/static/lib/twgsl/twgsl.js`,
25+
26+
// The scene is rendered to an antialiased texture, so we disable antialiasing on the canvas
27+
// to avoid the additional cost. This is only used for the UI which renders on top of the
28+
// post-processed scene, and we're typically happy with some aliasing on the UI.
29+
antialias: false
30+
};
31+
32+
const device = await pc.createGraphicsDevice(canvas, gfxOptions);
33+
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
34+
35+
const createOptions = new pc.AppOptions();
36+
createOptions.graphicsDevice = device;
37+
createOptions.mouse = new pc.Mouse(document.body);
38+
createOptions.touch = new pc.TouchDevice(document.body);
39+
createOptions.keyboard = new pc.Keyboard(window);
40+
41+
createOptions.componentSystems = [
42+
pc.RenderComponentSystem,
43+
pc.CameraComponentSystem,
44+
pc.LightComponentSystem,
45+
pc.ScriptComponentSystem
46+
];
47+
createOptions.resourceHandlers = [
48+
pc.TextureHandler,
49+
pc.ContainerHandler,
50+
pc.ScriptHandler
51+
];
52+
53+
const app = new pc.AppBase(canvas);
54+
app.init(createOptions);
55+
56+
// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
57+
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
58+
app.setCanvasResolution(pc.RESOLUTION_AUTO);
59+
60+
// Ensure canvas is resized when window changes size
61+
const resize = () => app.resizeCanvas();
62+
window.addEventListener('resize', resize);
63+
app.on('destroy', () => {
64+
window.removeEventListener('resize', resize);
65+
});
66+
67+
const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets);
68+
assetListLoader.load(() => {
69+
app.start();
70+
71+
// setup skydome with low intensity
72+
app.scene.envAtlas = assets.helipad.resource;
73+
app.scene.exposure = 1.2;
74+
75+
// create an instance of the apartment and add it to the scene
76+
const platformEntity = assets.apartment.resource.instantiateRenderEntity();
77+
platformEntity.setLocalScale(30, 30, 30);
78+
app.root.addChild(platformEntity);
79+
80+
// load a love sign model and add it to the scene
81+
const loveEntity = assets.love.resource.instantiateRenderEntity();
82+
loveEntity.setLocalPosition(-80, 30, -20);
83+
loveEntity.setLocalScale(130, 130, 130);
84+
loveEntity.rotate(0, -90, 0);
85+
app.root.addChild(loveEntity);
86+
87+
// make the love sign emissive to bloom
88+
const loveMaterial = loveEntity.findByName('s.0009_Standard_FF00BB_0').render.meshInstances[0].material;
89+
loveMaterial.emissive = pc.Color.YELLOW;
90+
loveMaterial.emissiveIntensity = 200;
91+
loveMaterial.update();
92+
93+
// adjust all materials of the love sign to disable dynamic refraction
94+
loveEntity.findComponents('render').forEach((render) => {
95+
render.meshInstances.forEach((meshInstance) => {
96+
meshInstance.material.useDynamicRefraction = false;
97+
});
98+
});
99+
100+
// Create an Entity with a camera component
101+
const cameraEntity = new pc.Entity();
102+
cameraEntity.addComponent('camera', {
103+
farClip: 1500,
104+
fov: 80
105+
});
106+
107+
const focusPoint = new pc.Entity();
108+
focusPoint.setLocalPosition(-80, 80, -20);
109+
110+
// add orbit camera script with a mouse and a touch support
111+
cameraEntity.addComponent('script');
112+
cameraEntity.script.create('orbitCamera', {
113+
attributes: {
114+
inertiaFactor: 0.2,
115+
focusEntity: focusPoint,
116+
distanceMax: 500,
117+
frameOnStart: false
118+
}
119+
});
120+
cameraEntity.script.create('orbitCameraInputMouse');
121+
cameraEntity.script.create('orbitCameraInputTouch');
122+
123+
cameraEntity.setLocalPosition(-50, 100, 220);
124+
cameraEntity.lookAt(0, 0, 100);
125+
app.root.addChild(cameraEntity);
126+
127+
// ------ Custom shader chunks for the camera frame ------
128+
129+
// Note: Override these empty chunks with your own custom code. Available chunk names:
130+
// - composeDeclarationsPS: declarations for your custom code
131+
// - composeMainStartPS: code to run at the start of the compose code
132+
// - composeMainEndPS: code to run at the end of the compose code
133+
134+
// Pixelation shader is based on this shadertoy shader: https://www.shadertoy.com/view/4dsXWs
135+
136+
// Define the pixelation helper in declarations so it's available in main
137+
pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL).set('composeDeclarationsPS', `
138+
uniform float pixelationTilePixels;
139+
uniform float pixelationIntensity;
140+
vec3 pixelateResult(vec3 color, vec2 uv, vec2 invRes) {
141+
vec2 tileUV = vec2(pixelationTilePixels, pixelationTilePixels) * invRes;
142+
vec2 centerUv = (floor(uv / tileUV) + 0.5) * tileUV;
143+
144+
vec2 local = (uv - centerUv) / tileUV;
145+
float dist = length(local);
146+
float radius = 0.35;
147+
float edge = fwidth(dist) * 1.5;
148+
float mask = 1.0 - smoothstep(radius, radius + edge, dist);
149+
vec3 dotResult = mix(vec3(0.0), color, mask);
150+
return mix(color, dotResult, pixelationIntensity);
151+
}
152+
`);
153+
154+
// WGSL equivalent declarations
155+
pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_WGSL).set('composeDeclarationsPS', `
156+
uniform pixelationTilePixels: f32;
157+
uniform pixelationIntensity: f32;
158+
fn pixelateResult(color: vec3f, uv: vec2f, invRes: vec2f) -> vec3f {
159+
let tileUV = vec2f(uniform.pixelationTilePixels, uniform.pixelationTilePixels) * invRes;
160+
let centerUv = (floor(uv / tileUV) + vec2f(0.5, 0.5)) * tileUV;
161+
let local = (uv - centerUv) / tileUV;
162+
let dist = length(local);
163+
let radius: f32 = 0.35;
164+
let edge: f32 = fwidth(dist) * 1.5;
165+
let mask: f32 = 1.0 - smoothstep(radius, radius + edge, dist);
166+
let dotResult = vec3f(0.0) * (1.0 - mask) + color * mask;
167+
return mix(color, dotResult, uniform.pixelationIntensity);
168+
}
169+
`);
170+
171+
// Call the helper at the end of compose to apply on top of previous effects
172+
pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL).set('composeMainEndPS', `
173+
result = pixelateResult(result, uv, sceneTextureInvRes);
174+
`);
175+
176+
// WGSL equivalent call
177+
pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_WGSL).set('composeMainEndPS', `
178+
result = pixelateResult(result, uv, uniform.sceneTextureInvRes);
179+
`);
180+
181+
// ------ Custom render passes set up ------
182+
183+
const cameraFrame = new pc.CameraFrame(app, cameraEntity.camera);
184+
cameraFrame.rendering.samples = 4;
185+
cameraFrame.bloom.intensity = 0.03;
186+
cameraFrame.bloom.blurLevel = 7;
187+
cameraFrame.vignette.inner = 0.5;
188+
cameraFrame.vignette.outer = 1;
189+
cameraFrame.vignette.curvature = 0.5;
190+
cameraFrame.vignette.intensity = 0.8;
191+
192+
cameraFrame.update();
193+
194+
// apply UI changes (tone mapping only)
195+
data.on('*:set', (/** @type {string} */ path, value) => {
196+
if (path === 'data.sceneTonemapping') {
197+
// postprocessing tone mapping
198+
cameraFrame.rendering.toneMapping = value;
199+
cameraFrame.update();
200+
}
201+
202+
if (path === 'data.pixelSize') {
203+
// global uniform for pixelation tile size
204+
device.scope.resolve('pixelationTilePixels').setValue(value);
205+
}
206+
207+
if (path === 'data.pixelationIntensity') {
208+
device.scope.resolve('pixelationIntensity').setValue(value);
209+
}
210+
});
211+
212+
// set initial values
213+
data.set('data', {
214+
sceneTonemapping: pc.TONEMAP_NEUTRAL,
215+
pixelSize: 8,
216+
pixelationIntensity: 0.5
217+
});
218+
});
219+
220+
export { app };
19.2 KB
Loading
748 Bytes
Loading

src/extras/render-passes/render-pass-compose.js

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Color } from '../../core/math/color.js';
33
import { RenderPassShaderQuad } from '../../scene/graphics/render-pass-shader-quad.js';
44
import { GAMMA_NONE, GAMMA_SRGB, gammaNames, TONEMAP_LINEAR, tonemapNames } from '../../scene/constants.js';
55
import { ShaderChunks } from '../../scene/shader-lib/shader-chunks.js';
6+
import { hashCode } from '../../core/hash.js';
67
import { SEMANTIC_POSITION, SHADERLANGUAGE_GLSL, SHADERLANGUAGE_WGSL } from '../../platform/graphics/constants.js';
78
import { ShaderUtils } from '../../scene/shader-lib/shader-utils.js';
89
import { composeChunksGLSL } from '../../scene/shader-lib/glsl/collections/compose-chunks-glsl.js';
@@ -19,6 +20,9 @@ import { composeChunksWGSL } from '../../scene/shader-lib/wgsl/collections/compo
1920
* @ignore
2021
*/
2122
class RenderPassCompose extends RenderPassShaderQuad {
23+
/**
24+
* @type {Texture|null}
25+
*/
2226
sceneTexture = null;
2327

2428
bloomIntensity = 0.01;
@@ -78,6 +82,13 @@ class RenderPassCompose extends RenderPassShaderQuad {
7882

7983
_debug = null;
8084

85+
// track user-provided custom compose chunks
86+
_customComposeChunks = new Map([
87+
['composeDeclarationsPS', ''],
88+
['composeMainStartPS', ''],
89+
['composeMainEndPS', '']
90+
]);
91+
8192
constructor(graphicsDevice) {
8293
super(graphicsDevice);
8394

@@ -247,12 +258,29 @@ class RenderPassCompose extends RenderPassShaderQuad {
247258
this._shaderDirty = true;
248259
}
249260

261+
const shaderChunks = ShaderChunks.get(this.device, this.device.isWebGPU ? SHADERLANGUAGE_WGSL : SHADERLANGUAGE_GLSL);
262+
263+
// detect changes to custom compose chunks and mark shader dirty
264+
for (const [name, prevValue] of this._customComposeChunks.entries()) {
265+
const currentValue = shaderChunks.get(name);
266+
if (currentValue !== prevValue) {
267+
this._customComposeChunks.set(name, currentValue);
268+
this._shaderDirty = true;
269+
}
270+
}
271+
250272
// need to rebuild shader
251273
if (this._shaderDirty) {
252274
this._shaderDirty = false;
253275

254276
const gammaCorrectionName = gammaNames[this._gammaCorrection];
255277

278+
// include hashes of custom compose chunks to ensure unique program for overrides
279+
const customChunks = this._customComposeChunks;
280+
const declHash = hashCode(customChunks.get('composeDeclarationsPS') ?? '');
281+
const startHash = hashCode(customChunks.get('composeMainStartPS') ?? '');
282+
const endHash = hashCode(customChunks.get('composeMainEndPS') ?? '');
283+
256284
const key =
257285
`${this.toneMapping}` +
258286
`-${gammaCorrectionName}` +
@@ -266,7 +294,8 @@ class RenderPassCompose extends RenderPassShaderQuad {
266294
`-${this.fringingEnabled ? 'fringing' : 'nofringing'}` +
267295
`-${this.taaEnabled ? 'taa' : 'notaa'}` +
268296
`-${this.isSharpnessEnabled ? 'cas' : 'nocas'}` +
269-
`-${this._debug ?? ''}`;
297+
`-${this._debug ?? ''}` +
298+
`-decl${declHash}-start${startHash}-end${endHash}`;
270299

271300
if (this._key !== key) {
272301
this._key = key;
@@ -286,7 +315,7 @@ class RenderPassCompose extends RenderPassShaderQuad {
286315
if (this.isSharpnessEnabled) defines.set('CAS', true);
287316
if (this._debug) defines.set('DEBUG_COMPOSE', this._debug);
288317

289-
const includes = new Map(ShaderChunks.get(this.device, this.device.isWebGPU ? SHADERLANGUAGE_WGSL : SHADERLANGUAGE_GLSL));
318+
const includes = new Map(shaderChunks);
290319

291320
this.shader = ShaderUtils.createShader(this.device, {
292321
uniqueName: `ComposeShader-${key}`,
@@ -302,9 +331,10 @@ class RenderPassCompose extends RenderPassShaderQuad {
302331

303332
execute() {
304333

305-
this.sceneTextureId.setValue(this.sceneTexture);
306-
this.sceneTextureInvResValue[0] = 1.0 / this.sceneTexture.width;
307-
this.sceneTextureInvResValue[1] = 1.0 / this.sceneTexture.height;
334+
const sceneTex = this.sceneTexture;
335+
this.sceneTextureId.setValue(sceneTex);
336+
this.sceneTextureInvResValue[0] = 1.0 / sceneTex.width;
337+
this.sceneTextureInvResValue[1] = 1.0 / sceneTex.height;
308338
this.sceneTextureInvResId.setValue(this.sceneTextureInvResValue);
309339

310340
if (this._bloomTexture) {

src/scene/shader-lib/glsl/chunks/render-pass/frag/compose/compose.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ export default /* glsl */`
1515
#include "composeCasPS"
1616
#include "composeColorLutPS"
1717
18+
#include "composeDeclarationsPS"
19+
1820
void main() {
21+
22+
#include "composeMainStartPS"
23+
1924
vec2 uv = uv0;
2025
2126
// TAA pass renders upside-down on WebGPU, flip it here
@@ -71,6 +76,8 @@ export default /* glsl */`
7176
result = applyVignette(result, uv);
7277
#endif
7378
79+
#include "composeMainEndPS"
80+
7481
// Debug output handling in one centralized location
7582
#ifdef DEBUG_COMPOSE
7683
#if DEBUG_COMPOSE == scene

0 commit comments

Comments
 (0)