Skip to content

Commit

Permalink
Get and Set texture transform (#4209)
Browse files Browse the repository at this point in the history
* Get and Set texture transform

* Adds feature #3368
* Adds modelviewer.dev example

* Texture Transform in Sampler

* constructor fix

* Sampler API working

Missing automated test

* Add test

* Document and clean gltf file.

* Revoke url from test

* Remove test.only

* Fix coding style.

* Clean index and test file
  • Loading branch information
diegoteran authored May 4, 2023
1 parent ed3ffd1 commit 3345a0a
Show file tree
Hide file tree
Showing 6 changed files with 215 additions and 11 deletions.
41 changes: 39 additions & 2 deletions packages/model-viewer/src/features/scene-graph/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import {AlphaMode, MagFilter, MinFilter, WrapMode} from '../../three-components/gltf-instance/gltf-2.0.js';



/**
* All constructs in a 3DOM scene graph have a corresponding string name.
* This is similar in spirit to the concept of a "tag name" in HTML, and exists
Expand All @@ -32,6 +31,12 @@ export declare interface ThreeDOMElementMap {
'texture-info': TextureInfo;
}

/** A 2D Cartesian coordinate */
export interface Vector2 {
x: number;
y: number;
}

/**
* A Model is the root element of a 3DOM scene graph. It gives scripts access
* to the sub-elements found without the graph.
Expand Down Expand Up @@ -201,7 +206,7 @@ export declare interface PBRMetallicRoughness {
*/
export declare interface TextureInfo {
/**
* The Texture being referenced by this TextureInfo
* The Texture being referenced by this TextureInfo.
*/
readonly texture: Texture|null;

Expand Down Expand Up @@ -266,6 +271,21 @@ export declare interface Sampler {
*/
readonly wrapT: WrapMode;

/**
* The texture rotation in radians.
*/
readonly rotation: number|null;

/**
* The texture scale.
*/
readonly scale: Vector2|null;

/**
* The texture offset.
*/
readonly offset: Vector2|null;

/**
* Configure the minFilter value of the Sampler.
*/
Expand All @@ -285,6 +305,23 @@ export declare interface Sampler {
* Configure the T (V) wrap mode of the Sampler.
*/
setWrapT(mode: WrapMode): void;

/**
* Sets the texture rotation, or resets it to zero if argument is null.
* Rotation is in radians, positive for counter-clockwise.
*/
setRotation(rotation: number|null): void;

/**
* Sets the texture scale, or resets it to (1, 1) if argument is null.
* As the scale value increases, the repetition of the texture will increase.
*/
setScale(scale: Vector2|null): void;

/**
* Sets the texture offset, or resets it to (0, 0) if argument is null.
*/
setOffset(offset: Vector2|null): void;
}


Expand Down
62 changes: 56 additions & 6 deletions packages/model-viewer/src/features/scene-graph/sampler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* limitations under the License.
*/

import {Texture as ThreeTexture} from 'three';
import {Texture as ThreeTexture, Vector2} from 'three';

import {Filter, MagFilter, MinFilter, Sampler as GLTFSampler, Wrap, WrapMode} from '../../three-components/gltf-instance/gltf-2.0.js';
import {Sampler as DefaultedSampler} from '../../three-components/gltf-instance/gltf-defaulted.js';
Expand Down Expand Up @@ -49,7 +49,7 @@ const isWrapMode = (() => {
wrapModes.indexOf(value as WrapMode) > -1;
})();

const isValidSamplerValue = <P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'>(
const isValidSamplerValue = <P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'|'rotation'|'repeat'|'offset'>(
property: P, value: unknown): value is DefaultedSampler[P] => {
switch (property) {
case 'minFilter':
Expand All @@ -59,11 +59,16 @@ const isValidSamplerValue = <P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'>(
case 'wrapS':
case 'wrapT':
return isWrapMode(value);
case 'rotation':
case 'repeat':
case 'offset':
return true;
default:
throw new Error(`Cannot configure property "${property}" on Sampler`);
}
};

const $threeTexture = Symbol('threeTexture');
const $threeTextures = Symbol('threeTextures');
const $setProperty = Symbol('setProperty');
const $sourceSampler = Symbol('sourceSampler');
Expand All @@ -72,6 +77,13 @@ const $sourceSampler = Symbol('sourceSampler');
* Sampler facade implementation for Three.js textures
*/
export class Sampler extends ThreeDOMElement implements SamplerInterface {
private get[$threeTexture]() {
console.assert(
this[$correlatedObjects] != null && this[$correlatedObjects]!.size > 0,
'Sampler correlated object is undefined');
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
}

private get[$threeTextures]() {
console.assert(
this[$correlatedObjects] != null && this[$correlatedObjects]!.size > 0,
Expand Down Expand Up @@ -132,6 +144,18 @@ export class Sampler extends ThreeDOMElement implements SamplerInterface {
return this[$sourceSampler].wrapT;
}

get rotation(): number {
return this[$threeTexture].rotation;
}

get scale(): Vector2 {
return this[$threeTexture].repeat;
}

get offset(): Vector2|null {
return this[$threeTexture].offset;
}

setMinFilter(filter: MinFilter) {
this[$setProperty]('minFilter', filter);
}
Expand All @@ -148,15 +172,41 @@ export class Sampler extends ThreeDOMElement implements SamplerInterface {
this[$setProperty]('wrapT', mode);
}

private[$setProperty]<P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'>(
property: P, value: MinFilter|MagFilter|WrapMode) {
setRotation(rotation: number|null): void {
if(rotation == null) {
// Reset rotation.
rotation = 0;
}
this[$setProperty]('rotation', rotation);
}

setScale(scale: Vector2|null): void {
if(scale == null) {
// Reset scale.
scale = new Vector2(1, 1);
}
this[$setProperty]('repeat', scale);
}

setOffset(offset: Vector2|null): void {
if(offset == null) {
// Reset offset.
offset = new Vector2(0, 0);
}
this[$setProperty]('offset', offset);
}

private[$setProperty]<P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'|'rotation'|'repeat'|'offset'>(
property: P, value: MinFilter|MagFilter|WrapMode|number|Vector2) {
const sampler = this[$sourceSampler];
if (sampler != null) {
if (isValidSamplerValue(property, value)) {
sampler[property] = value;
if (property !== 'rotation' && property !== 'repeat' && property !== 'offset') {
sampler[property] = value;
}

for (const texture of this[$threeTextures]) {
(texture[property] as MinFilter | MagFilter | WrapMode) = value;
(texture[property] as MinFilter | MagFilter | WrapMode | number | Vector2) = value;
texture.needsUpdate = true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* limitations under the License.
*/

import { Vector2 } from 'three';
import {TextureInfo} from '../../../features/scene-graph/texture-info.js';
import {ModelViewerElement} from '../../../model-viewer.js';
import {waitForEvent} from '../../../utilities.js';
Expand All @@ -21,6 +22,7 @@ import {assetPath} from '../../helpers.js';
const expect = chai.expect;
const DUCK_GLB_PATH =
assetPath('models/glTF-Sample-Models/2.0/Duck/glTF-Binary/Duck.glb');
const TEXTURED_CUBE_GLB_PATH = assetPath('models/glTF-Sample-Models/2.0/BoxTextured/glTF-Binary/BoxTextured.glb');

suite('scene-graph/texture-info', () => {
suite('texture-info', () => {
Expand Down Expand Up @@ -65,5 +67,32 @@ suite('scene-graph/texture-info', () => {
emptyTextureInfo.setTexture(null);
expect(emptyTextureInfo.texture).to.be.null;
});

test('exports and re-imports the model with transformed texture', async () => {
// Load textured glb.
element.src = TEXTURED_CUBE_GLB_PATH;
await waitForEvent(element, 'load');

// Transform the textures.
const sampler = element.model?.materials[0].pbrMetallicRoughness['baseColorTexture'].texture?.sampler!;
sampler.setRotation(0.1);
sampler.setOffset(new Vector2(0.2, 0.3));
sampler.setScale(new Vector2(0.4, 0.5));

// Export model.
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);

// Re-load model.
element.src = url;
await waitForEvent(element, 'load');

URL.revokeObjectURL(url);

const exported_sampler = element.model?.materials[0].pbrMetallicRoughness['baseColorTexture'].texture?.sampler!;
expect(exported_sampler.rotation).to.be.eq(0.1, 'rotation');
expect(exported_sampler.offset).to.be.eql(new Vector2(0.2, 0.3), 'offset');
expect(exported_sampler.scale).to.be.eql(new Vector2(0.4, 0.5), 'scale');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {Vector2} from 'three';

import {Accessor, AlphaMode, AnimationSampler, Asset, Camera, ExtensionDictionary, Extras, MagFilter, Mesh, MinFilter, RGB, RGBA, Scene, WrapMode} from './gltf-2.0';


Expand All @@ -9,6 +11,9 @@ export interface Sampler {
wrapT: WrapMode;
extensions?: ExtensionDictionary;
extras?: Extras;
rotation: number;
repeat: Vector2;
offset: Vector2;
}

export interface Texture {
Expand Down
6 changes: 5 additions & 1 deletion packages/modelviewer.dev/data/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,13 @@
"name": "Create Textures"
},
{
"htmlId": "swapTextures",
"htmlId": "swapTexturesExample",
"name": "Swap Textures"
},
{
"htmlId": "transformTexturesExample",
"name": "Transform Textures"
},
{
"htmlId": "animatedTexturesExample",
"name": "Animated Textures"
Expand Down
83 changes: 81 additions & 2 deletions packages/modelviewer.dev/examples/scenegraph/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ <h2 class="demo-title">Create textures</h2>


<div class="sample">
<div id="swapTextures" class="demo"></div>
<div id="swapTexturesExample" class="demo"></div>
<div class="content">
<div class="wrapper">
<div class="heading">
Expand All @@ -388,7 +388,7 @@ <h2 class="demo-title">Swap textures</h2>
mode. iOS Quick Look reflects these texture changes so long as the
USDZ is auto-generated.</p>
</div>
<example-snippet stamp-to="swapTextures" highlight-as="html">
<example-snippet stamp-to="swapTexturesExample" highlight-as="html">
<template>
<model-viewer id="helmet" camera-controls touch-action="pan-y" src="../../shared-assets/models/glTF-Sample-Models/2.0/DamagedHelmet/glTF-Binary/DamagedHelmet.glb" ar alt="A 3D model of a helmet">
<div class="controls">
Expand Down Expand Up @@ -477,6 +477,77 @@ <h2 class="demo-title">Swap textures</h2>
</div>
</div>


<div class="sample">
<div id="transformTexturesExample" class="demo"></div>
<div class="content">
<div class="wrapper">
<div class="heading">
<h2 class="demo-title">Transform textures</h2>
<p>As above, you can change these values in AR, but only in WebXR
mode. iOS Quick Look reflects these texture changes so long as the
USDZ is auto-generated.</p>
</div>
<example-snippet stamp-to="transformTexturesExample" highlight-as="html">
<template>
<model-viewer id="box" camera-controls touch-action="pan-y" src="../../shared-assets/models/glTF-Sample-Models/2.0/BoxTextured/glTF-Binary/BoxTextured.glb" ar alt="A 3D model of a helmet">
<div class="controls">
<p>Rotation: <span id="texture-rotation"></span></p>
<input type="range" min="0" max="3.14" value="0" step="0.01" id="rotationSlider">
<p>Scale: <span id="texture-scale"></span></p>
<input type="range" min="0.5" max="1.5" value="1" step="0.01" id="scaleSlider">
<p>Offset</p>
<input type="range" min="0" max="1" value="0" step="0.01" id="offsetSlider">
</div>
</model-viewer>
<script type="module">
const modelViewerTexture2 = document.querySelector("model-viewer#box");
const rotationSlider = document.querySelector('#rotationSlider');
const scaleSlider = document.querySelector('#scaleSlider');
const offsetSlider = document.querySelector('#offsetSlider');

modelViewerTexture2.addEventListener("load", () => {

const sampler = modelViewerTexture2.model.materials[0].pbrMetallicRoughness['baseColorTexture'].texture.sampler;

const rotationDisplay = document.querySelector('#texture-rotation');
const scaleDisplay = document.querySelector('#texture-scale');

rotationDisplay.textContent = rotationSlider.value;
scaleDisplay.textContent = scaleSlider.value;

rotationSlider.addEventListener('input', (event) => {
const rotation = rotationSlider.value;
sampler.setRotation(rotation);
rotationDisplay.textContent = rotation;
});

scaleSlider.addEventListener('input', (event) => {
const scale = {
x: scaleSlider.value,
y: scaleSlider.value
};
sampler.setScale(scale);
scaleDisplay.textContent = scale.x;
});

offsetSlider.addEventListener('input', (event) => {
const offset = {
x: offsetSlider.value,
y: -offsetSlider.value
};
sampler.setOffset(offset);
});
});

</script>
</template>
</example-snippet>
</div>
</div>
</div>


<div class="sample">
<div id="animatedTexturesExample" class="demo"></div>
<div class="content">
Expand Down Expand Up @@ -699,6 +770,9 @@ <h2 class="demo-title">Materials API</h2>
setMagFilter(filter: MagFilter): void;
setWrapS(mode: WrapMode): void;
setWrapT(mode: WrapMode): void;
setRotation(rotation: number|null): void;
setScale(scale: Vector2|null): void;
setOffset(offset: Vector2|null): void;
}

interface Image {
Expand Down Expand Up @@ -737,6 +811,11 @@ <h2 class="demo-title">Materials API</h2>
update(): void;
}

interface Vector2 {
readonly x: number;
readonly y: number;
}

type RGBA = [number, number, number, number];
type RGB = [number, number, number];
type AlphaMode = 'OPAQUE'|'MASK'|'BLEND';
Expand Down

0 comments on commit 3345a0a

Please sign in to comment.