<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>Custom RenderNode - Cross-fade slide transition | Sample | ArcGIS Maps SDK for JavaScript</title>
<!-- Load the ArcGIS Maps SDK for JavaScript from CDN -->
<script type="module" src="https://js.arcgis.com/5.0/"></script>
border: 2px solid transparent;
border: 2px solid var(--calcite-color-brand);
<arcgis-scene item-id="cc2071118509424790a8762666fe5a19">
<arcgis-zoom slot="top-left"></arcgis-zoom>
<arcgis-navigation-toggle slot="top-left"></arcgis-navigation-toggle>
<arcgis-compass slot="top-left"></arcgis-compass>
<calcite-block id="controls" expanded="" heading="Crossfade slide transition">
<calcite-segmented-control id="crossfade-speed">
<calcite-segmented-control-item value="1">fast</calcite-segmented-control-item>
<calcite-segmented-control-item value="3" checked=""
>medium</calcite-segmented-control-item
<calcite-segmented-control-item value="5">slow</calcite-segmented-control-item>
</calcite-segmented-control>
const [RenderNode] = await $arcgis.import(["@arcgis/core/views/3d/webgl/RenderNode.js"]);
// Get the reference to the Scene component
// and its view once component is ready
const viewElement = document.querySelector("arcgis-scene");
await viewElement.viewOnReady();
const view = viewElement.view;
// When the FadeRenderNode is constructed it immediately captures the frame and starts fading.
// The FadeRenderNode takes the current framebuffer and the initially captured framebuffer
// and blends them according to animation time.
// Once the animation is done, The FadeRenderNode destroys itself.
const FadeRenderNode = RenderNode.createSubclass({
constructor: function () {
this.consumes.required.push("composite-color");
this.produces = "composite-color";
// render one frame to capture initial framebuffer
// ready will resolve once render node has captured start framebuffer on first render
this.ready = new Promise((resolve) => (this._resolve = resolve));
const now = performance.now();
const input = inputs.find(({ name }) => name === "composite-color");
// hold on to the start framebuffer when the render function runs the first time
if (!this._startFramebuffer) {
this._startFramebuffer = input;
this._startFramebuffer.retain(); //retain ensures framebuffer is kept in memory
return this._startFramebuffer;
// check if the animation has finished or continue fading
const delta = (now - this._startTime) / this.duration;
// animation has finished
this._startFramebuffer.release();
this._startFramebuffer = null;
// to apply the crossfading we need a new output framebuffer
const output = this.acquireOutputFramebuffer();
gl.clearColor(0, 0, 0, 1);
gl.colorMask(true, true, true, true);
gl.clear(gl.COLOR_BUFFER_BIT);
this.ensureScreenSpacePass();
gl.useProgram(this._program);
gl.uniform1f(this._deltaLocation, delta);
// there are two texture inputs into the fragment shader. First the current
// framebuffer and second the start framebuffer.
const currentTex = input.getTexture(gl.COLOR_ATTACHMENT0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, currentTex.glName);
gl.uniform1i(this._currentLocation, 0);
const startTex = this._startFramebuffer.getTexture(gl.COLOR_ATTACHMENT0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, startTex.glName);
gl.uniform1i(this._startLocation, 1);
// bind and draw a screen space filling triangle
gl.bindVertexArray(this._vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
// use depth from input on output framebuffer
output.attachDepth(input.getAttachment(gl.DEPTH_STENCIL_ATTACHMENT));
this.gl?.deleteProgram(this._program);
if (this._positionBuffer) {
this.gl?.deleteBuffer(this._positionBuffer);
this.gl?.deleteVertexArray(this._vao);
ensureScreenSpacePass() {
this._vao = gl.createVertexArray();
gl.bindVertexArray(this._vao);
this._positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this._positionBuffer);
const vertices = new Float32Array([-1.0, -1.0, 3.0, -1.0, -1.0, 3.0]);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.vertexAttribPointer(this._positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(this._positionLocation);
gl.bindVertexArray(null);
if (this._program != null) {
const vshader = `#version 300 es
gl_Position = vec4(position, 0.0, 1.0);
uv = position * 0.5 + vec2(0.5);
// the fragment shader blends between the start and the current framebuffer weighted by time since start (delta)
const fshader = `#version 300 es
uniform sampler2D currentTex;
uniform sampler2D startTex;
vec4 current = texture(currentTex, uv);
vec4 start = texture(startTex, uv);
fragColor = mix(start, current, delta);
const program = createProgram(gl, vshader, fshader);
if (program) this._program = program;
this._currentLocation = gl.getUniformLocation(this._program, "currentTex");
this._startLocation = gl.getUniformLocation(this._program, "startTex");
this._positionLocation = gl.getAttribLocation(this._program, "position");
this._deltaLocation = gl.getUniformLocation(this._program, "delta");
function createShader(gl, src, type) {
const shader = gl.createShader(type);
console.error("Failed to create shader");
gl.shaderSource(shader, src);
gl.compileShader(shader);
function createProgram(gl, vsSource, fsSource) {
const program = gl.createProgram();
console.error("Failed to create program");
const vertexShader = createShader(gl, vsSource, gl.VERTEX_SHADER);
const fragmentShader = createShader(gl, fsSource, gl.FRAGMENT_SHADER);
if (!vertexShader || !fragmentShader) {
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
console.error(`Failed to link program:
info log: ${gl.getProgramInfoLog(program)},
vertex: ${gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)},
fragment: ${gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)}
vertex info log: ${gl.getShaderInfoLog(vertexShader)},
fragment info log: ${gl.getShaderInfoLog(fragmentShader)}`);
const slidesNode = document.getElementsByTagName("slides")[0];
const speedSelect = document.getElementById("crossfade-speed");
map.presentation.slides.forEach((slide) => {
// create a clickable thumbnail for each slide
const slideThumbnail = document.createElement("img");
slideThumbnail.src = slide.thumbnail.url;
slideThumbnail.title = slide.title.text;
slidesNode.append(slideThumbnail);
slideThumbnail.onclick = async () => {
// create a new fade render node, and set its animation speed
const node = new FadeRenderNode({ view });
node.duration = speedSelect.value * 1000;
// Wait for node to capture current view before going to next one
// apply slide without fly-to animation
slide.applyTo(view, { animate: false });