From c7ca69981e134725825788c7b1eff7578d95575f Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 25 Dec 2023 15:25:34 +0900 Subject: [PATCH] Add dither fade example (#421) * Add fade utils * Delete line * Updates * Updates to fade example * Cleanup * Add fade example * Add an example of dither fade tiles * Remove unused field * Update the fade tiles demo * Styles update * Last update * Fix the fade out logic when fade in has not happened * Ensure tiles outside the visible area are not visible / rendered on a frame * Add fast moving camera support * Fade improvements * Adjust fade positino * Update examples, fix fade --- example/fadingTiles.html | 52 ++++++ example/fadingTiles.js | 143 ++++++++++++++++ example/src/FadeManager.js | 283 +++++++++++++++++++++++++++++++ example/src/FadeTilesRenderer.js | 221 ++++++++++++++++++++++++ 4 files changed, 699 insertions(+) create mode 100644 example/fadingTiles.html create mode 100644 example/fadingTiles.js create mode 100644 example/src/FadeManager.js create mode 100644 example/src/FadeTilesRenderer.js diff --git a/example/fadingTiles.html b/example/fadingTiles.html new file mode 100644 index 000000000..39fd0cdcf --- /dev/null +++ b/example/fadingTiles.html @@ -0,0 +1,52 @@ + + + + + + + Dither Fade Tiles + + + + +
+ Demonstration of tiles use a dither fade to change, smoothing out the transition. +
+ + + diff --git a/example/fadingTiles.js b/example/fadingTiles.js new file mode 100644 index 000000000..6c4c6a5f2 --- /dev/null +++ b/example/fadingTiles.js @@ -0,0 +1,143 @@ +import { + FadeTilesRenderer, +} from './src/FadeTilesRenderer.js'; +import { + Scene, + DirectionalLight, + AmbientLight, + WebGLRenderer, + PerspectiveCamera, + Group, +} from 'three'; +import { FlyOrbitControls } from './FlyOrbitControls.js'; +import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'; + +let camera, controls, scene, renderer; +let groundTiles, skyTiles, tilesParent; + +const params = { + + reinstantiateTiles, + useFade: true, + errorTarget: 12, + fadeDuration: 0.5, + renderScale: 1, + fadingGroundTiles: '0 tiles', + +}; + +init(); +render(); + +function init() { + + scene = new Scene(); + + // primary camera view + renderer = new WebGLRenderer( { antialias: true } ); + renderer.setPixelRatio( window.devicePixelRatio ); + renderer.setSize( window.innerWidth, window.innerHeight ); + renderer.setClearColor( 0xd8cec0 ); + + document.body.appendChild( renderer.domElement ); + renderer.domElement.tabIndex = 1; + + camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 1, 4000 ); + camera.position.set( 20, 10, 20 ); + + // controls + controls = new FlyOrbitControls( camera, renderer.domElement ); + controls.screenSpacePanning = false; + controls.minDistance = 1; + controls.maxDistance = 2000; + controls.maxPolarAngle = Math.PI / 2; + controls.baseSpeed = 0.1; + controls.fastSpeed = 0.2; + + // lights + const dirLight = new DirectionalLight( 0xffffff ); + dirLight.position.set( 1, 2, 3 ); + scene.add( dirLight ); + + const ambLight = new AmbientLight( 0xffffff, 0.2 ); + scene.add( ambLight ); + + tilesParent = new Group(); + tilesParent.rotation.set( Math.PI / 2, 0, 0 ); + scene.add( tilesParent ); + + reinstantiateTiles(); + + onWindowResize(); + window.addEventListener( 'resize', onWindowResize, false ); + + const gui = new GUI(); + gui.add( params, 'useFade' ); + gui.add( params, 'errorTarget', 0, 1000 ); + gui.add( params, 'fadeDuration', 0, 5 ); + gui.add( params, 'renderScale', 0.1, 1.0, 0.05 ).onChange( v => renderer.setPixelRatio( v * window.devicePixelRatio ) ); + + const textController = gui.add( params, 'fadingGroundTiles' ).listen().disable(); + textController.domElement.style.opacity = 1.0; + + gui.add( params, 'reinstantiateTiles' ); + + gui.open(); + +} + +function reinstantiateTiles() { + + if ( groundTiles ) { + + groundTiles.dispose(); + skyTiles.dispose(); + + } + + groundTiles = new FadeTilesRenderer( 'https://raw.githubusercontent.com/NASA-AMMOS/3DTilesSampleData/master/msl-dingo-gap/0528_0260184_to_s64o256_colorize/0528_0260184_to_s64o256_colorize/0528_0260184_to_s64o256_colorize_tileset.json' ); + groundTiles.fetchOptions.mode = 'cors'; + groundTiles.lruCache.minSize = 900; + groundTiles.lruCache.maxSize = 1300; + groundTiles.errorTarget = 12; + + skyTiles = new FadeTilesRenderer( 'https://raw.githubusercontent.com/NASA-AMMOS/3DTilesSampleData/master/msl-dingo-gap/0528_0260184_to_s64o256_colorize/0528_0260184_to_s64o256_sky/0528_0260184_to_s64o256_sky_tileset.json' ); + skyTiles.fetchOptions.mode = 'cors'; + skyTiles.lruCache = groundTiles.lruCache; + + tilesParent.add( groundTiles.group, skyTiles.group ); + +} + +function onWindowResize() { + + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize( window.innerWidth, window.innerHeight ); + +} + +function render() { + + requestAnimationFrame( render ); + + camera.updateMatrixWorld(); + + groundTiles.errorTarget = params.errorTarget; + + groundTiles.setCamera( camera ); + groundTiles.setResolutionFromRenderer( camera, renderer ); + groundTiles.update(); + + skyTiles.setCamera( camera ); + skyTiles.setResolutionFromRenderer( camera, renderer ); + skyTiles.update(); + + groundTiles.fadeDuration = params.useFade ? params.fadeDuration * 1000 : 0; + skyTiles.fadeDuration = params.useFade ? params.fadeDuration * 1000 : 0; + + renderer.render( scene, camera ); + + params.fadingGroundTiles = groundTiles._fadeGroup.children.length + ' tiles'; + +} diff --git a/example/src/FadeManager.js b/example/src/FadeManager.js new file mode 100644 index 000000000..98df92202 --- /dev/null +++ b/example/src/FadeManager.js @@ -0,0 +1,283 @@ +import { MathUtils } from 'three'; + +const { clamp } = MathUtils; +export class FadeManager { + + constructor() { + + this.duration = 250; + this._lastTick = - 1; + this._fadeState = new Map(); + this._fadeParams = new WeakMap(); + this.onFadeFinish = () => {}; + + } + + // initialize materials in the object + prepareObject( object ) { + + object.traverse( child => { + + if ( child.material ) { + + this.prepareMaterial( child.material ); + + } + + } ); + + } + + deleteObject( object ) { + + if ( ! object ) { + + return; + + } + + this._fadeParams.delete( object ); + object.traverse( child => { + + const material = child.material; + if ( material ) { + + this._fadeParams.delete( material ); + + } + + } ); + + } + + // initialize the material + prepareMaterial( material ) { + + const fadeParams = this._fadeParams; + if ( fadeParams.has( material ) ) { + + return; + + } + + const params = { + fadeIn: { value: 0 }, + fadeOut: { value: 0 }, + }; + + material.defines = { + FEATURE_FADE: 0, + }; + + material.onBeforeCompile = shader => { + + shader.uniforms = { + ...shader.uniforms, + ...params, + }; + + shader.fragmentShader = shader.fragmentShader + .replace( /void main\(/, value => /* glsl */` + #if FEATURE_FADE + + // adapted from https://www.shadertoy.com/view/Mlt3z8 + float bayerDither2x2( vec2 v ) { + + return mod( 3.0 * v.y + 2.0 * v.x, 4.0 ); + + } + + float bayerDither4x4( vec2 v ) { + + vec2 P1 = mod( v, 2.0 ); + vec2 P2 = floor( 0.5 * mod( v, 4.0 ) ); + return 4.0 * bayerDither2x2( P1 ) + bayerDither2x2( P2 ); + + } + + uniform float fadeIn; + uniform float fadeOut; + #endif + + ${ value } + ` ) + .replace( /#include /, value => /* glsl */` + + ${ value } + + #if FEATURE_FADE + + float bayerValue = bayerDither4x4( floor( mod( gl_FragCoord.xy, 4.0 ) ) ); + float bayerBins = 16.0; + float dither = ( 0.5 + bayerValue ) / bayerBins; + if ( dither >= fadeIn ) { + + discard; + + } + + if ( dither < fadeOut ) { + + discard; + + } + + #endif + + + ` ); + + + }; + + fadeParams.set( material, params ); + + } + + guaranteeState( object ) { + + const fadeState = this._fadeState; + if ( fadeState.has( object ) ) { + + return false; + + } + + const state = { + fadeInTarget: 0, + fadeOutTarget: 0, + fadeIn: 0, + fadeOut: 0, + }; + + fadeState.set( object, state ); + + const fadeParams = this._fadeParams; + object.traverse( child => { + + const material = child.material; + if ( material && fadeParams.has( material ) ) { + + const params = fadeParams.get( material ); + params.fadeIn.value = 0; + params.fadeOut.value = 0; + + } + + } ); + + return true; + + } + + completeFade( object ) { + + const fadeState = this._fadeState; + if ( ! fadeState.has( object ) ) return; + + fadeState.delete( object ); + object.traverse( child => { + + const material = child.material; + if ( material && material.defines.FEATURE_FADE !== 0 ) { + + material.defines.FEATURE_FADE = 0; + material.needsUpdate = true; + + } + + } ); + + this.onFadeFinish( object ); + + } + + fadeIn( object ) { + + this.guaranteeState( object ); + + const state = this._fadeState.get( object ); + state.fadeInTarget = 1; + state.fadeOutTarget = 0; + state.fadeOut = 0; + + } + + fadeOut( object ) { + + const noState = this.guaranteeState( object ); + const state = this._fadeState.get( object ); + state.fadeOutTarget = 1; + if ( noState ) { + + state.fadeInTarget = 1; + state.fadeIn = 1; + + } + + } + + update() { + + // clamp delta in case duration is really small or 0 + const time = window.performance.now(); + const delta = clamp( ( time - this._lastTick ) / this.duration, 0, 1 ); + this._lastTick = time; + + const fadeState = this._fadeState; + const fadeParams = this._fadeParams; + fadeState.forEach( ( state, object ) => { + + // tick the fade values + const { + fadeOutTarget, + fadeInTarget, + } = state; + + let { + fadeOut, + fadeIn, + } = state; + + const fadeInSign = Math.sign( fadeInTarget - fadeIn ); + fadeIn = clamp( fadeIn + fadeInSign * delta, 0, 1 ); + + const fadeOutSign = Math.sign( fadeOutTarget - fadeOut ); + fadeOut = clamp( fadeOut + fadeOutSign * delta, 0, 1 ); + + state.fadeIn = fadeIn; + state.fadeOut = fadeOut; + + // update the material fields + const defineValue = Number( fadeOut !== fadeOutTarget || fadeIn !== fadeInTarget ); + object.traverse( child => { + + const material = child.material; + if ( material && fadeParams.has( material ) ) { + + const uniforms = fadeParams.get( material ); + uniforms.fadeIn.value = fadeIn; + uniforms.fadeOut.value = fadeOut; + + if ( defineValue !== material.defines.FEATURE_FADE ) { + + material.defines.FEATURE_FADE = defineValue; + material.needsUpdate = true; + + } + + } + + } ); + + if ( fadeOut === 1.0 || fadeOut >= fadeIn ) { + + this.completeFade( object ); + + } + + } ); + + } + +} diff --git a/example/src/FadeTilesRenderer.js b/example/src/FadeTilesRenderer.js new file mode 100644 index 000000000..3de698750 --- /dev/null +++ b/example/src/FadeTilesRenderer.js @@ -0,0 +1,221 @@ +import { Group, Matrix4, Vector3, Quaternion } from 'three'; +import { TilesRenderer } from '../..'; +import { FadeManager } from './FadeManager.js'; + +const _fromPos = new Vector3(); +const _toPos = new Vector3(); +const _fromQuat = new Quaternion(); +const _toQuat = new Quaternion(); +const _scale = new Vector3(); + +function onTileVisibilityChange( scene, tile, visible ) { + + // ensure the tiles are marked as visible on visibility toggle since + // it's possible we disable them when adjusting visibility based on frustum + scene.visible = true; + + if ( ! visible ) { + + this._fadeGroup.add( scene ); + this._fadeManager.fadeOut( scene ); + + } else { + + this._fadeManager.fadeIn( scene ); + + } + + // ensure the tiles are fading to the right target before stopping fade + if ( this.isMovingFast ) { + + this._fadeManager.completeFade( scene ); + + } + +} + +function onLoadModel( scene ) { + + this._fadeManager.prepareObject( scene ); + +} + +function onFadeFinish( object ) { + + // when the fade finishes ensure we dispose the tile and remove it from the fade group + if ( object.parent === this._fadeGroup ) { + + this._fadeGroup.remove( object ); + + if ( this.disposeSet.has( object ) ) { + + // TODO: a lot of this is basically redundant to the TilesRenderer.disposeTile code + this._fadeManager.deleteObject( object ); + object.traverse( child => { + + const { geometry, material } = child; + if ( geometry ) { + + geometry.dispose(); + + } + + if ( material ) { + + material.dispose(); + for ( const key in material ) { + + const value = material[ key ]; + if ( value && value.dispose && typeof value.dispose === 'function' ) { + + value.dispose(); + + } + + } + + } + + } ); + + } + + } + +} + +export const FadeTilesRendererMixin = base => class extends base { + + get fadeDuration() { + + return this._fadeManager.duration; + + } + + set fadeDuration( value ) { + + this._fadeManager.duration = Number( value ); + + } + + constructor( ...args ) { + + super( ...args ); + + const fadeGroup = new Group(); + const fadeManager = new FadeManager(); + this.group.add( fadeGroup ); + fadeManager.onFadeFinish = onFadeFinish.bind( this ); + + this._fadeManager = fadeManager; + this._fadeGroup = fadeGroup; + + this.onLoadModel = onLoadModel.bind( this ); + this.onTileVisibilityChange = onTileVisibilityChange.bind( this ); + + this.prevCameraTransform = new Map(); + this.disposeSet = new Set(); + + } + + update( ...args ) { + + // determine whether all the rendering cameras are moving + // quickly so we can adjust how tiles fade accordingly + let isMovingFast = true; + const prevCameraTransform = this.prevCameraTransform; + const cameras = this.cameras; + cameras.forEach( camera => { + + if ( ! prevCameraTransform.has( camera ) ) { + + return; + + } + + const currMatrix = camera.matrixWorld; + const prevMatrix = prevCameraTransform.get( camera ); + + currMatrix.decompose( _toPos, _toQuat, _scale ); + prevMatrix.decompose( _fromPos, _fromQuat, _scale ); + + const angleTo = _toQuat.angleTo( _fromQuat ); + const positionTo = _toPos.distanceTo( _fromPos ); + + // if rotation is moving > 0.25 radians per frame or position is moving > 0.1 units + // then we are considering the camera to be moving too fast to notice a faster / abrupt fade + isMovingFast = isMovingFast && ( angleTo > 0.25 || positionTo > 0.1 ); + + } ); + + // adjust settings to what's needed for specific fade logic. Ie display active tiles so when the camera + // moves we don't notice tiles popping when they enter the view. And perform the fade animation more quickly + // if the camera is moving quickly. + const fadeDuration = this.fadeDuration; + const displayActiveTiles = this.displayActiveTiles; + this.displayActiveTiles = true; + this.fadeDuration = isMovingFast ? fadeDuration * 0.2 : fadeDuration; + this.isMovingFast = isMovingFast; + + // update the tiles + super.update( ...args ); + this._fadeManager.update(); + + this.displayActiveTiles = displayActiveTiles; + this.fadeDuration = fadeDuration; + + // update the visibility of tiles based on visibility since we must use + // the active tiles for rendering fade + if ( ! displayActiveTiles ) { + + this.visibleTiles.forEach( t => { + + t.cached.scene.visible = t.__inFrustum; + + } ); + + } + + // track the camera movement so we can use it for next frame + cameras.forEach( camera => { + + if ( ! prevCameraTransform.has( camera ) ) { + + prevCameraTransform.set( camera, new Matrix4() ); + + } + + prevCameraTransform.get( camera ).copy( camera.matrixWorld ); + + } ); + + } + + deleteCamera( camera ) { + + this.prevCameraTransform.delete( camera ); + + } + + disposeTile( tile ) { + + // When a tile is disposed we keep it around if it's currently fading out and mark it for disposal later + const scene = tile.cached.scene; + if ( scene && scene.parent === this._fadeGroup ) { + + this.disposeSet.add( scene ); + super.disposeTile( tile ); + this._fadeGroup.add( scene ); + + } else { + + super.disposeTile( tile ); + this._fadeManager.deleteObject( scene ); + + } + + } + +}; + +export const FadeTilesRenderer = FadeTilesRendererMixin( TilesRenderer );