Skip to content

Commit

Permalink
added Grounded Skybox option (#4604)
Browse files Browse the repository at this point in the history
* added GroundedSkybox

* updated docs

* more docs

* added tests
  • Loading branch information
elalish authored Dec 22, 2023
1 parent ff13ec1 commit a35cafc
Show file tree
Hide file tree
Showing 11 changed files with 192 additions and 33 deletions.
3 changes: 1 addition & 2 deletions packages/model-viewer/src/features/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,8 +799,7 @@ export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
* orbiting at the supplied radius.
*/
[$updateCameraForRadius](radius: number) {
const maximumRadius =
Math.max(this[$scene].boundingSphere.radius, radius);
const maximumRadius = Math.max(this[$scene].farRadius(), radius);

const near = 0;
const far = Math.abs(2 * maximumRadius);
Expand Down
9 changes: 9 additions & 0 deletions packages/model-viewer/src/features/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const $cancelEnvironmentUpdate = Symbol('cancelEnvironmentUpdate');
export declare interface EnvironmentInterface {
environmentImage: string|null;
skyboxImage: string|null;
skyboxHeight: string;
shadowIntensity: number;
shadowSoftness: number;
exposure: number;
Expand All @@ -60,6 +61,9 @@ export const EnvironmentMixin = <T extends Constructor<ModelViewerElementBase>>(
@property({type: String, attribute: 'tone-mapping'})
toneMapping: ToneMappingValue = 'auto';

@property({type: String, attribute: 'skybox-height'})
skyboxHeight: string = '0';

protected[$currentEnvironmentMap]: Texture|null = null;
protected[$currentBackground]: Texture|null = null;

Expand Down Expand Up @@ -93,6 +97,11 @@ export const EnvironmentMixin = <T extends Constructor<ModelViewerElementBase>>(
this[$shouldAttemptPreload]()) {
this[$updateEnvironment]();
}

if (changedProperties.has('skyboxHeight')) {
this[$scene].setGroundedSkybox();
this[$needsRender]();
}
}

hasBakedShadow(): boolean {
Expand Down
27 changes: 27 additions & 0 deletions packages/model-viewer/src/test/features/environment-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,33 @@ suite('Environment', () => {
expect(scene.environment!.name).to.be.eq(element.skyboxImage);
});

test('has tight radius', async function() {
expect(scene.farRadius()).to.be.lessThan(2);
});

suite('with skybox-height property', () => {
setup(async () => {
element.setAttribute('skybox-height', '1m');
await element.updateComplete;
});

test('switches background', async function() {
expect(scene.background).to.be.null;
});

test('has wide radius', async function() {
expect(scene.farRadius()).to.be.greaterThan(2);
});

test('no skybox-image disables grounded skybox', async function() {
element.setAttribute('skybox-image', '');
await element.updateComplete;
await rafPasses();
await rafPasses();
expect(scene.farRadius()).to.be.lessThan(2);
});
});

suite('with an environment-image', () => {
setup(async () => {
const environmentChanged = waitForEvent(element, 'environment-change');
Expand Down
74 changes: 74 additions & 0 deletions packages/model-viewer/src/three-components/GroundedSkybox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* @license
* Copyright 2023 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {BackSide, BufferAttribute, Mesh, MeshBasicMaterial, SphereGeometry, Texture, Vector3} from 'three';

export class GroundedSkybox extends Mesh {
private height = 0;
private radius = 0;
private resolution = 0;

constructor() {
super(undefined, new MeshBasicMaterial({side: BackSide}));
this.userData.noHit = true;
}

get map() {
return (this.material as MeshBasicMaterial).map;
}

set map(skybox: Texture|null) {
(this.material as MeshBasicMaterial).map = skybox;
}

isUsable() {
return this.height > 0 && this.radius > 0 && this.geometry != null &&
this.map != null;
}

updateGeometry(height = this.height, radius = this.radius, resolution = 128) {
if (height != this.height || radius != this.radius ||
resolution != this.resolution) {
this.height = height;
this.radius = radius;
this.resolution = resolution;
if (height > 0 && radius > 0) {
this.geometry.dispose();
this.geometry = makeGeometry(height, radius, resolution);
}
}
}
}

function makeGeometry(height: number, radius: number, resolution: number) {
const geometry = new SphereGeometry(radius, 2 * resolution, resolution);

const pos = geometry.getAttribute('position') as BufferAttribute;
const tmp = new Vector3();
for (let i = 0; i < pos.count; ++i) {
tmp.fromBufferAttribute(pos, i);
if (tmp.y < 0) {
// Smooth out the transition from flat floor to sphere:
const y1 = -height * 3 / 2;
const f =
tmp.y < y1 ? -height / tmp.y : (1 - tmp.y * tmp.y / (3 * y1 * y1));
tmp.multiplyScalar(f);
tmp.toArray(pos.array, 3 * i);
}
}
pos.needsUpdate = true;

return geometry;
}
45 changes: 39 additions & 6 deletions packages/model-viewer/src/three-components/ModelScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

import {AnimationAction, AnimationActionLoopStyles, AnimationClip, AnimationMixer, Box3, Camera, Euler, Event as ThreeEvent, LoopPingPong, LoopRepeat, Material, Matrix3, Mesh, Object3D, PerspectiveCamera, Raycaster, Scene, Sphere, Texture, Triangle, Vector2, Vector3, WebGLRenderer} from 'three';
import {CSS2DRenderer} from 'three/examples/jsm/renderers/CSS2DRenderer.js';
// @ts-ignore
import {reduceVertices} from 'three/examples/jsm/utils/SceneUtils.js';

import {ToneMappingValue} from '../features/environment.js';
Expand All @@ -29,9 +28,11 @@ import {resolveDpr} from '../utilities.js';

import {Damper, SETTLING_TIME} from './Damper.js';
import {ModelViewerGLTFInstance} from './gltf-instance/ModelViewerGLTFInstance.js';
import {GroundedSkybox} from './GroundedSkybox.js';
import {Hotspot} from './Hotspot.js';
import {Shadow} from './Shadow.js';

export const GROUNDED_SKYBOX_SIZE = 10;
const MIN_SHADOW_RATIO = 100;

export interface ModelLoadEvent extends ThreeEvent {
Expand Down Expand Up @@ -115,6 +116,8 @@ export class ModelScene extends Scene {
private animationsByName: Map<string, AnimationClip> = new Map();
private currentAnimationAction: AnimationAction|null = null;

private groundedSkybox = new GroundedSkybox();

constructor({canvas, element, width, height}: ModelSceneConfig) {
super();

Expand Down Expand Up @@ -275,6 +278,8 @@ export class ModelScene extends Scene {

this.updateShadow();
this.setShadowIntensity(this.shadowIntensity);

this.setGroundedSkybox();
}

reset() {
Expand Down Expand Up @@ -344,12 +349,12 @@ export class ModelScene extends Scene {
}

markBakedShadow(mesh: Mesh) {
mesh.userData.shadow = true;
mesh.userData.noHit = true;
this.bakedShadows.add(mesh);
}

unmarkBakedShadow(mesh: Mesh) {
mesh.userData.shadow = false;
mesh.userData.noHit = false;
mesh.visible = true;
this.bakedShadows.delete(mesh);
this.boundingBox.expandByObject(mesh);
Expand Down Expand Up @@ -536,10 +541,39 @@ export class ModelScene extends Scene {
return;
}
this.environment = environment;
this.background = skybox;
this.setBackground(skybox);
this.queueRender();
}

setBackground(skybox: Texture|null) {
this.groundedSkybox.map = skybox;
if (this.groundedSkybox.isUsable()) {
this.target.add(this.groundedSkybox);
this.background = null;
} else {
this.target.remove(this.groundedSkybox);
this.background = skybox;
}
}

farRadius() {
return this.boundingSphere.radius *
(this.groundedSkybox.parent != null ? GROUNDED_SKYBOX_SIZE : 1);
}

setGroundedSkybox() {
const heightNode =
parseExpressions(this.element.skyboxHeight)[0].terms[0] as NumberNode;
const height = normalizeUnit(heightNode).number;
const radius = GROUNDED_SKYBOX_SIZE * this.boundingSphere.radius;

this.groundedSkybox.updateGeometry(height, radius);
this.groundedSkybox.position.y =
height - (this.shadow ? 2 * this.shadow.gap() : 0);

this.setBackground(this.groundedSkybox.map);
}

/**
* Sets the point in model coordinates the model should orbit/pivot around.
*/
Expand Down Expand Up @@ -815,8 +849,7 @@ export class ModelScene extends Scene {
raycaster.setFromCamera(ndcPosition, this.getCamera());
const hits = raycaster.intersectObject(object, true);

return hits.find(
(hit) => hit.object.visible && !hit.object.userData.shadow);
return hits.find((hit) => hit.object.visible && !hit.object.userData.noHit);
}

/**
Expand Down
8 changes: 6 additions & 2 deletions packages/model-viewer/src/three-components/Shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export class Shadow extends Object3D {
side: BackSide,
});
this.floor = new Mesh(plane, shadowMaterial);
this.floor.userData.shadow = true;
this.floor.userData.noHit = true;
camera.add(this.floor);

// the plane onto which to blur the texture
Expand Down Expand Up @@ -264,7 +264,11 @@ export class Shadow extends Object3D {
* z-fighting with any baked-in shadow plane.
*/
setOffset(offset: number) {
this.floor.position.z = -offset + 0.001 * this.maxDimension;
this.floor.position.z = -offset + this.gap();
}

gap() {
return 0.001 * this.maxDimension;
}

render(renderer: WebGLRenderer, scene: Scene) {
Expand Down
14 changes: 13 additions & 1 deletion packages/modelviewer.dev/data/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -841,11 +841,23 @@
{
"name": "skybox-image",
"htmlName": "skyboxImage",
"description": "Sets the background image of the scene. Takes a URL to an <a href=\"https://en.wikipedia.org/wiki/Equirectangular_projection\">equirectangular projection image</a> that's used for the skybox, as well as applied as an environment map on the model. Supports png, jpg and hdr (recommended) images.",
"description": "Sets the background image of the scene. Takes a URL to an <a href=\"https://en.wikipedia.org/wiki/Equirectangular_projection\">equirectangular projection image</a> that's used for the skybox, as well as applied as an environment map on the model. Supports png, hdr, and jpg (including UltraHDR) images. HDR images are strongly recommended to adequately represent lighting, and the UltraHDR JPEG format is particularly recommended for its high compression of HDR data - try it yourself using this free online <a href=\"https://gainmap-creator.monogrid.com\">converter</a>.",
"links": [
"<a href=\"../examples/lightingandenv/\">Related examples</a>"
]
},
{
"name": "skybox-height",
"htmlName": "skyboxHeight",
"description": "Causes the skybox to be projected onto the ground plane. The height indicates the camera's distance above the ground and acts to scale the image at ground level to the correct size. Accepts units in meters (\"m\"), centimeters (\"cm\"), or millimeters (\"mm\"). The default value of 0m disables ground projection.",
"links": [
"<a href=\"../examples/lightingandenv/#groundedSkybox\">Related examples</a>"
],
"default": {
"default": "0m",
"options": "any positive value"
}
},
{
"name": "environment-image",
"htmlName": "environmentImage",
Expand Down
6 changes: 3 additions & 3 deletions packages/modelviewer.dev/data/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@
"name": "HDR skybox-image"
},
{
"htmlId": "litModel",
"name": "Lit Model"
"htmlId": "groundedSkybox",
"name": "Grounded Skybox"
},
{
"htmlId": "unlitModel",
Expand Down Expand Up @@ -303,4 +303,4 @@
}
]
}
]
]
4 changes: 2 additions & 2 deletions packages/modelviewer.dev/examples/augmentedreality/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ <h4>This demonstrates several augmented reality modes, including
</div>
<example-snippet stamp-to="ar" highlight-as="html">
<template>
<model-viewer src="../../shared-assets/models/Astronaut.glb" ar ar-scale="fixed" camera-controls touch-action="pan-y" alt="A 3D model of an astronaut" skybox-image="../../shared-assets/environments/aircraft_workshop_01_1k.hdr" ios-src="../../shared-assets/models/Astronaut.usdz" xr-environment></model-viewer>
<model-viewer src="../../shared-assets/models/Astronaut.glb" ar ar-scale="fixed" camera-controls touch-action="pan-y" alt="A 3D model of an astronaut" shadow-intensity="2" skybox-image="../../shared-assets/environments/spruit_sunrise_1k_HDR.jpg" skybox-height="2m" max-camera-orbit="auto 90deg auto" ios-src="../../shared-assets/models/Astronaut.usdz" xr-environment></model-viewer>
</template>
</example-snippet>

Expand All @@ -316,7 +316,7 @@ <h4>Here the Scene Viewer app is given priority, to make it easier to compare wi
</div>
<example-snippet stamp-to="sceneViewer" highlight-as="html">
<template>
<model-viewer id="model-viewer" src="../../shared-assets/models/Astronaut.glb" ar ar-modes="scene-viewer webxr" camera-controls touch-action="pan-y" alt="A 3D model of an astronaut" skybox-image="../../shared-assets/environments/aircraft_workshop_01_1k.hdr">
<model-viewer id="model-viewer" src="../../shared-assets/models/Astronaut.glb" ar ar-modes="scene-viewer webxr" camera-controls touch-action="pan-y" alt="A 3D model of an astronaut" shadow-intensity="2" skybox-image="../../shared-assets/environments/spruit_sunrise_1k_HDR.jpg" skybox-height="2m" max-camera-orbit="auto 90deg auto">
<div id="error" class="hide">AR is not supported on this device</div>
</model-viewer>
<script>
Expand Down
Loading

0 comments on commit a35cafc

Please sign in to comment.