Skip to content

Commit

Permalink
Add <GLTFScene>, loadGLB, and Duck example (cruise-automation#64)
Browse files Browse the repository at this point in the history
  • Loading branch information
jtbandes authored Jan 23, 2019
1 parent cd559fa commit f380ceb
Show file tree
Hide file tree
Showing 12 changed files with 421 additions and 0 deletions.
19 changes: 19 additions & 0 deletions docs/src/4.12.GLTFScene.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {DuckScene} from './jsx/allLiveEditors';

# GLTFScene

`<GLTFScene />` provides support for loading and rendering [glTF models](https://www.khronos.org/gltf/). Only **Binary glTF** (`.glb`, without external resources) and basic color textures are currently supported.

## Props

| Name | Type | Default | Description |
| ------- | ---------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `model` | `string | (() => Promise<Object>)` | | URL of a `.glb` file to load, or an async function resolving to a loaded model. (A `parseGLB` function is also exported if you want custom logic for loading the model.) The file must define a single `scene`. |
| `pose` | `Pose` | zero position, identity orientation | position and orientation at which to render the scene |
| `scale` | `Scale` | `{ x: 1, y: 1, z: 1 }` | scale factor |

## Duck.glb

This example shows a common sample model from [KhronosGroup/glTF-Sample-Models](https://github.com/KhronosGroup/glTF-Sample-Models/tree/master/2.0).

<DuckScene />
Binary file added docs/src/Duck.glb
Binary file not shown.
38 changes: 38 additions & 0 deletions docs/src/jsx/DuckScene.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) 2019-present, GM Cruise LLC
//
// This source code is licensed under the Apache License, Version 2.0,
// found in the LICENSE file in the root directory of this source tree.
// You may not use this file except in compliance with the License.

// #BEGIN EXAMPLE
import React from "react";

import duckModel from "../Duck.glb"; // URL pointing to a .glb file
import Worldview, { Axes, Grid, GLTFScene, DEFAULT_CAMERA_STATE } from "regl-worldview";

// #BEGIN EDITABLE
function DuckScene() {
return (
<Worldview
defaultCameraState={{
...DEFAULT_CAMERA_STATE,
distance: 15,
thetaOffset: (-3 * Math.PI) / 4,
}}>
<Axes />
<Grid />
<GLTFScene model={duckModel}>
{{
pose: {
position: { x: 0, y: 0, z: 0 },
orientation: { x: 0, y: 0, z: 0, w: 1 },
},
scale: { x: 3, y: 3, z: 3 },
}}
</GLTFScene>
</Worldview>
);
}
// #END EXAMPLE

export default DuckScene;
5 changes: 5 additions & 0 deletions docs/src/jsx/WorldviewCodeEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import React, { useState, useEffect } from "react";
import seedrandom from "seedrandom";
import styled from "styled-components";

import duckModel from "../Duck.glb";
import CameraStateInfo from "./CameraStateInfo";
import CodeEditor from "./CodeEditor";
import InputNumber from "./InputNumber";
Expand All @@ -29,6 +30,7 @@ import Worldview, {
FilledPolygons,
Overlay,
Text,
GLTFScene,
DEFAULT_CAMERA_STATE,
withPose,
getCSSColor,
Expand Down Expand Up @@ -63,7 +65,10 @@ export const scope = {
FilledPolygons,
Overlay,
Text,
GLTFScene,
withPose,

duckModel,
};

export default function WorldviewCodeEditor({ scope: customScope = {}, ...rest }) {
Expand Down
2 changes: 2 additions & 0 deletions docs/src/jsx/allDemos.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Composition from "./Composition";
import Cones from "./Cones";
import Cubes from "./Cubes";
import Cylinders from "./Cylinders";
import DuckScene from "./DuckScene";
import DynamicCommands from "./DynamicCommands";
import FilledPolygons from "./FilledPolygons";
import Hitmap from "./Hitmap";
Expand Down Expand Up @@ -50,6 +51,7 @@ const allDemos = {
SpheresSingle,
Text,
Triangles,
DuckScene,
};

const stories = storiesOf("Worldview docs", module);
Expand Down
2 changes: 2 additions & 0 deletions docs/src/jsx/allLiveEditors.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const Cubes = makeCodeComponent(require("!!raw-loader!./Cubes"));

export const Cylinders = makeCodeComponent(require("!!raw-loader!./Cylinders"));

export const DuckScene = makeCodeComponent(require("!!raw-loader!./DuckScene"));

export const DynamicCommands = makeCodeComponent(require("!!raw-loader!./DynamicCommands"));

export const FilledPolygons = makeCodeComponent(require("!!raw-loader!./FilledPolygons"));
Expand Down
3 changes: 3 additions & 0 deletions docs/src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import BrowserSupport from "./3.5.BrowserSupport.mdx";
import Arrows from "./4.1.Arrows.mdx";
import Text from "./4.10.Text.mdx";
import Triangles from "./4.11.Triangles.mdx";
import GLTFScene from "./4.12.GLTFScene.mdx";
import Cones from "./4.2.Cones.mdx";
import Cubes from "./4.3.Cubes.mdx";
import Cylinders from "./4.4.Cylinders.mdx";
Expand Down Expand Up @@ -47,6 +48,7 @@ export const componentList = {
Text,
Triangles,
Flow,
GLTFScene,
BrowserSupport,
};

Expand Down Expand Up @@ -74,6 +76,7 @@ const ROUTE_CONFIG = [
"Spheres",
"Text",
"Triangles",
"GLTFScene",
],
},
];
Expand Down
241 changes: 241 additions & 0 deletions packages/regl-worldview/src/commands/GLTFScene.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// @flow

// Copyright (c) 2019-present, GM Cruise LLC
//
// This source code is licensed under the Apache License, Version 2.0,
// found in the LICENSE file in the root directory of this source tree.
// You may not use this file except in compliance with the License.

import { mat4 } from "gl-matrix";
import React from "react";

import parseGLB from "../utils/parseGLB";

import { Command, pointToVec3, orientationToVec4, type Pose, type Scale, WorldviewReactContext } from "..";

function glConstantToRegl(value: ?number): ?string {
if (value === undefined) {
return undefined;
}
// prettier-ignore
switch (value) {
// min/mag filters
case WebGLRenderingContext.NEAREST: return "nearest";
case WebGLRenderingContext.LINEAR: return "linear";
case WebGLRenderingContext.NEAREST_MIPMAP_NEAREST: return "nearest mipmap nearest";
case WebGLRenderingContext.NEAREST_MIPMAP_LINEAR: return "nearest mipmap linear";
case WebGLRenderingContext.LINEAR_MIPMAP_NEAREST: return "linear mipmap nearest";
case WebGLRenderingContext.LINEAR_MIPMAP_LINEAR: return "linear mipmap linear";
// texture wrapping modes
case WebGLRenderingContext.REPEAT: return "repeat";
case WebGLRenderingContext.CLAMP_TO_EDGE: return "clamp";
case WebGLRenderingContext.MIRRORED_REPEAT: return "mirror";
}
throw new Error(`unhandled constant value ${JSON.stringify(value)}`);
}

const drawModel = (regl) => {
const command = regl({
primitive: "triangles",
uniforms: {
baseColorTexture: regl.prop("baseColorTexture"),
nodeMatrix: regl.prop("nodeMatrix"),
poseMatrix: regl.context("poseMatrix"),
"light.direction": [0, 0, -1],
"light.ambientIntensity": 0.5,
"light.diffuseIntensity": 0.5,
},
attributes: {
position: regl.prop("positions"),
normal: regl.prop("normals"),
texCoord: regl.prop("texCoords"),
},
elements: regl.prop("indices"),
vert: `
uniform mat4 projection, view;
uniform mat4 nodeMatrix;
uniform mat4 poseMatrix;
attribute vec3 position, normal;
varying vec3 vNormal;
attribute vec2 texCoord;
varying vec2 vTexCoord;
void main() {
// using the projection matrix for normals breaks lighting for orthographic mode
mat4 mv = view * poseMatrix * nodeMatrix;
vNormal = normalize((mv * vec4(normal, 0)).xyz);
vTexCoord = texCoord;
gl_Position = projection * mv * vec4(position, 1);
}
`,
frag: `
precision mediump float;
uniform sampler2D baseColorTexture;
varying mediump vec2 vTexCoord;
varying mediump vec3 vNormal;
// Basic directional lighting from:
// http://ogldev.atspace.co.uk/www/tutorial18/tutorial18.html
struct DirectionalLight {
mediump vec3 direction;
lowp float ambientIntensity;
lowp float diffuseIntensity;
};
uniform DirectionalLight light;
void main() {
vec3 baseColor = texture2D(baseColorTexture, vTexCoord).rgb;
float diffuse = light.diffuseIntensity * max(0.0, dot(vNormal, -light.direction));
gl_FragColor = vec4((light.ambientIntensity + diffuse) * baseColor, 1);
}
`,
});

// Build the draw calls needed to draw the model. This only needs to happen once, since they
// are the same each time, with only poseMatrix changing.
let drawCalls;
function prepareDrawCallsIfNeeded(model) {
if (drawCalls) {
return;
}

// upload textures to the GPU
const textures = model.json.textures.map((textureInfo) => {
const sampler = model.json.samplers[textureInfo.sampler];
const bitmap: ImageBitmap = model.images[textureInfo.source];
const texture = regl.texture({
data: bitmap,
min: glConstantToRegl(sampler.minFilter),
mag: glConstantToRegl(sampler.magFilter),
wrapS: glConstantToRegl(sampler.wrapS),
wrapT: glConstantToRegl(sampler.wrapT),
});
bitmap.close();
return texture;
});
drawCalls = [];

// helper to draw the primitives comprising a mesh
function drawMesh(mesh, nodeMatrix) {
for (const primitive of mesh.primitives) {
const material = model.json.materials[primitive.material];
const texInfo = material.pbrMetallicRoughness.baseColorTexture;
drawCalls.push({
indices: model.accessors[primitive.indices],
positions: model.accessors[primitive.attributes.POSITION],
normals: model.accessors[primitive.attributes.NORMAL],
texCoords: model.accessors[primitive.attributes[`TEXCOORD_${texInfo.texCoord || 0}`]],
baseColorTexture: textures[texInfo.index],
nodeMatrix,
});
}
}

// helper to draw all the meshes contained in a node and its child nodes
function drawNode(node, parentMatrix) {
const nodeMatrix = node.matrix
? mat4.clone(node.matrix)
: mat4.fromRotationTranslationScale(
mat4.create(),
node.rotation || [0, 0, 0, 1],
node.translation || [0, 0, 0],
node.scale || [1, 1, 1]
);
mat4.mul(nodeMatrix, parentMatrix, nodeMatrix);
if (node.mesh != null) {
drawMesh(model.json.meshes[node.mesh], nodeMatrix);
}
if (node.children) {
for (const childIdx of node.children) {
drawNode(model.json.nodes[childIdx], nodeMatrix);
}
}
}

// finally, draw each of the main scene's nodes
for (const nodeIdx of model.json.scenes[model.json.scene].nodes) {
const rootTransform = mat4.create();
mat4.rotateX(rootTransform, rootTransform, Math.PI / 2);
mat4.rotateY(rootTransform, rootTransform, Math.PI / 2);
drawNode(model.json.nodes[nodeIdx], rootTransform);
}
}

// create a regl command to set the context for each draw call
const withPoseMatrix = regl({
context: {
poseMatrix: (context, props) =>
mat4.fromRotationTranslationScale(
mat4.create(),
orientationToVec4(props.pose.orientation),
pointToVec3(props.pose.position),
props.scale ? pointToVec3(props.scale) : [1, 1, 1]
),
},
});

return (props) => {
prepareDrawCallsIfNeeded(props.model);
withPoseMatrix(props, () => {
command(drawCalls);
});
};
};

type Props = {|
model: string | (() => Promise<Object>),
children: {|
pose: Pose,
scale: Scale,
|},
|};

export default class GLTFScene extends React.Component<Props, {| loadedModel: ?Object |}> {
state = {
loadedModel: undefined,
};
_context = undefined;

async _loadModel(): Promise<Object> {
const { model } = this.props;
if (typeof model === "function") {
return model();
} else if (typeof model === "string") {
const response = await fetch(model);
if (!response.ok) {
throw new Error(`failed to fetch GLB: ${response.status}`);
}
return parseGLB(await response.arrayBuffer());
}
/*:: (model: empty) */
throw new Error(`unsupported model prop: ${typeof model}`);
}

componentDidMount() {
this._loadModel()
.then((loadedModel) => {
this.setState({ loadedModel });
if (this._context) {
this._context.onDirty();
}
})
.catch((err) => {
console.error("error loading GLB model:", err);
});
}

render() {
const { loadedModel } = this.state;
if (!loadedModel) {
return null;
}
return (
<WorldviewReactContext.Consumer>
{(context) => {
this._context = context;
return <Command reglCommand={drawModel} drawProps={{ model: loadedModel, ...this.props.children }} />;
}}
</WorldviewReactContext.Consumer>
);
}
}
1 change: 1 addition & 0 deletions packages/regl-worldview/src/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ export { default as FilledPolygons } from "./FilledPolygons";
// Other
export { default as Overlay } from "./Overlay";
export { default as Text } from "./Text";
export { default as GLTFScene } from "./GLTFScene";
1 change: 1 addition & 0 deletions packages/regl-worldview/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { selectors as cameraStateSelectors, CameraStore, DEFAULT_CAMERA_STATE }
export * from "./utils/commandUtils";
export { default as eulerFromQuaternion } from "./utils/eulerFromQuaternion";
export { default as fromGeometry } from "./utils/fromGeometry";
export { default as parseGLB } from "./utils/parseGLB";
export * from "./utils/Raycast";
export * from "./commands/index";
export * from "./types/index";
Expand Down
Loading

0 comments on commit f380ceb

Please sign in to comment.