Repository: vue-gl/vue-gl
Pull request #554 (2019-06-03 04:44:11)

Add component wrapper for THREE.ShaderMaterial
This PR adds a `VglShaderMaterial` component that wraps [THREE.ShaderMaterial](https://threejs.org/docs/index.html#api/en/materials/ShaderMaterial), allowing users to write custom shaders for their objects.

I left out some of the props that seemed more specialized (mainly because I wasn't sure how to test them).  I'm also curious if there is a better way to handle the `lights` and `fog` props; since THREE only uses them during the initial construction of the shader, changing their values after the fact has no effect.  Is there a good way to communicate that to users?  (Maybe just a watcher that emits a warning to the console?)

I will also note that I only do web stuff as a hobby, so all feedback is appreciated!


Patch:
diff --git a/README.md b/README.md
index 64bf109..684b8ce 100644
--- a/README.md
+++ b/README.md
@@ -183,7 +183,7 @@ When you open the html above in the browser, you'll see below.
         Corresponding to [THREE.PointsMaterial](https://threejs.org/docs/index.html#api/materials/PointsMaterial)
   - [ ] **[VglRawShaderMaterial](src/materials/vgl-raw-shader-material.js)** -
         Corresponding to [THREE.RawShaderMaterial](https://threejs.org/docs/index.html#api/materials/RawShaderMaterial)
-  - [ ] **[VglShaderMaterial](src/materials/vgl-shader-material.js)** -
+  - [X] **[VglShaderMaterial](src/materials/vgl-shader-material.js)** -
         Corresponding to [THREE.ShaderMaterial](https://threejs.org/docs/index.html#api/materials/ShaderMaterial)
   - [x] **[VglShadowMaterial](src/materials/vgl-shadow-material.js)** -
         Corresponding to [THREE.ShadowMaterial](https://threejs.org/docs/index.html#api/materials/ShadowMaterial)
diff --git a/docs/examples/materials/vgl-shader-material.html b/docs/examples/materials/vgl-shader-material.html
new file mode 100644
index 0000000..4502ef8
--- /dev/null
+++ b/docs/examples/materials/vgl-shader-material.html
@@ -0,0 +1,130 @@
+---
+---
+<div id="app">
+  <vgl-renderer antialias style="height: 100vh;">
+    <vgl-scene>
+      <vgl-icosahedron-geometry name="geo" radius="1" detail="5" />
+      <vgl-shader-material
+        ref="mat"
+        name="mat"
+        wireframe
+        :defines="defines"
+        :uniforms="uniforms"
+        :vertex-shader="vertexShader"
+        :fragment-shader="fragmentShader"
+      />
+      <vgl-mesh geometry="geo" material="mat" />
+    </vgl-scene>
+    <vgl-perspective-camera orbit-position="5 0.8 0.2"></vgl-perspective-camera>
+  </vgl-renderer>
+
+  <aside class="control-panel">
+    <section>
+      <h3>Shaders</h3>
+      <label>
+        Vertex Shader [<a @click='vertShaderShown = !vertShaderShown'>
+          {% raw %}{{vertShaderShown ? 'Hide' : 'Show'}}{% endraw %}
+        </a>]
+      </label>
+      <textarea :style='{display: vertShaderShown ? "block" : "none"}' rows="14" cols="50" v-model="vertexShader"></textarea>
+      <label>
+        Fragment Shader [<a @click='fragShaderShown = !fragShaderShown'>
+          {% raw %}{{fragShaderShown ? 'Hide' : 'Show'}}{% endraw %}
+        </a>]
+      </label>
+      <textarea :style='{display: fragShaderShown ? "block" : "none"}' rows="14" cols="50" v-model="fragmentShader"></textarea>
+    </section>
+    <section>
+      <h3>Vert Shader Uniforms</h3>
+      <table>
+        <tr><td>Offset</td><td><input class="slider" type="range" max="2" step="0.01" v-model.number="uniforms.waveOffset.value" /></td></tr>
+        <tr><td>Amplitude</td><td><input class="slider" type="range" max="2" step="0.01" v-model.number="uniforms.waveAmp.value" /></td></tr>
+        <tr><td>Frequency</td><td><input class="slider" type="range" max="50" v-model.number="uniforms.waveFreq.value" /></td></tr>
+      </table>
+      <label>
+        <h3>Frag Shader Uniforms</h3>
+        Frag Color
+        <select v-model.number="uniforms.displayColor.value">
+          <option value="0" selected>Cartesian</option>
+          <option value="1">Spherical</option>
+          <option value="2">Depth</option>
+        </select>
+      </label>
+    </section>
+  </aside>
+</div>
+
+<script>
+  new Vue({
+    el: '#app',
+    data: {
+      vertShaderShown: false,
+      fragShaderShown: false,
+      defines: {
+        COLOR_CARTESIAN: 0,
+        COLOR_SPHERICAL: 1,
+        COLOR_DEPTH: 2,
+      },
+      uniforms: {
+        waveOffset:   { value: 0.0 },
+        waveAmp:      { value: 0.2 },
+        waveFreq:     { value: 10  },
+        displayColor: { value: 0 },
+      },
+      vertexShader: `
+
+uniform float waveOffset;
+uniform float waveAmp;
+uniform float waveFreq;
+
+varying vec4 vCartesian;
+varying vec4 vSpherical;
+
+void main() {
+  float r = length(position);
+  float theta = acos(position.y / r); // inclination
+  float phi = atan(position.z, position.x); // azimuth
+
+  float finalR = r + sin((theta + waveOffset) * waveFreq) * waveAmp;
+  vec4 finalPos = vec4(
+    finalR * sin(theta) * cos(phi),
+    finalR * cos(theta),
+    finalR * sin(theta) * sin(phi),
+    1.0
+  );
+
+  gl_Position = projectionMatrix * modelViewMatrix * finalPos;
+
+  const float PI = 3.141592654;
+  vCartesian = finalPos;
+  vSpherical = vec4( // normalize values to [0, 1]
+    r,
+    theta / PI,
+    ((phi / PI) + 1.0) / 2.0,
+    1.0
+  );
+}
+
+      `.trim(),
+      fragmentShader: `
+
+uniform int displayColor;
+
+varying vec4 vSpherical;
+varying vec4 vCartesian;
+
+void main() {
+  if (displayColor == COLOR_CARTESIAN) {
+    gl_FragColor = vCartesian;
+  } else if (displayColor == COLOR_SPHERICAL) {
+    gl_FragColor = vSpherical;
+  } else if (displayColor == COLOR_DEPTH) {
+    float depth = gl_FragCoord.z * gl_FragCoord.w;
+    gl_FragColor = vec4(vec3(depth), 1.0);
+  }
+}
+
+      `.trim(),
+    },
+  });
+</script>
diff --git a/src/index.d.ts b/src/index.d.ts
index d1d46c8..bfecb0d 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -58,6 +58,7 @@ export const VglMeshNormalMaterial: object;
 export const VglMeshPhongMaterial: object;
 export const VglMeshToonMaterial: object;
 export const VglMeshPhysicalMaterial: object;
+export const VglShaderMaterial: object;
 export const VglLatheGeometry: object;
 export const VglSpotLightHelper: object;
 export const VglHemisphereLight: object;
diff --git a/src/index.js b/src/index.js
index d429a55..876897e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -56,6 +56,7 @@ import VglMeshNormalMaterial from './materials/vgl-mesh-normal-material';
 import VglMeshPhongMaterial from './materials/vgl-mesh-phong-material';
 import VglMeshToonMaterial from './materials/vgl-mesh-toon-material';
 import VglMeshPhysicalMaterial from './materials/vgl-mesh-physical-material';
+import VglShaderMaterial from './materials/vgl-shader-material';
 import VglLatheGeometry from './geometries/vgl-lathe-geometry';
 import VglSpotLightHelper from './helpers/vgl-spot-light-helper';
 import VglHemisphereLight from './lights/vgl-hemisphere-light';
@@ -120,6 +121,7 @@ export {
   VglMeshPhongMaterial,
   VglMeshToonMaterial,
   VglMeshPhysicalMaterial,
+  VglShaderMaterial,
   VglLatheGeometry,
   VglSpotLightHelper,
   VglHemisphereLight,
diff --git a/src/materials/vgl-shader-material.js b/src/materials/vgl-shader-material.js
new file mode 100644
index 0000000..20d9156
--- /dev/null
+++ b/src/materials/vgl-shader-material.js
@@ -0,0 +1,170 @@
+import { ShaderMaterial } from 'three';
+import { VglMaterialWithMap } from '../mixins';
+import { boolean, number, string } from '../validators';
+
+/**
+ * A material for drawing geometries with custom vertex or fragment shaders, corresponding to
+ * [THREE.ShaderMaterial](https://threejs.org/docs/index.html#api/materials/ShaderMaterial).
+ *
+ * Properties of [VglMaterial](vgl-material) are also available as a mixin.
+ *
+ * Note that some of these properties are not efficient to update. See
+ * [here](https://threejs.org/docs/#manual/en/introduction/How-to-update-things) for some caveats
+ * about updating material state.
+ */
+
+export default {
+  mixins: [VglMaterialWithMap],
+
+  props: {
+    /**
+     * Defines custom constants using `#define` directives within the GLSL code for both the vertex
+     * shader and the fragment shader; each key/value pair yields another directive:
+     * ```
+     * defines: {
+     *     FOO: 15,
+     *     BAR: true
+     * }
+     * ```
+     * yields the lines
+     * ```
+     * #define FOO 15
+     * #define BAR true
+     * ```
+     * in the GLSL code.
+     * @default {}
+     */
+    defines: { type: Object, default: () => ({}) },
+    /**
+     * Define whether the material color is affected by global fog settings; true to pass fog
+     * uniforms to the shader. Note that changing this value will cause the material to be
+     * reconstructed, so be aware of performance if using this reactively. Also note that when using
+     * this, THREE expects fog-related uniforms to be defined on your material; you can use
+     * something like the following to include them:
+     * ```
+     * uniforms: THREE.UniformsUtils.merge([
+     *    THREE.UniformsLib['fog'],
+     *    { other uniforms... }
+     * ]),
+     * ```
+     */
+    fog: { type: boolean, default: false },
+    /**
+     * Fragment shader GLSL code. This is the actual code for the shader.
+     * @default The default fragment shader provided by three.js
+     */
+    fragmentShader: { type: string },
+    /**
+     * Defines whether this material uses lighting; true to pass uniform data related to lighting to
+     * this shader. Note that changing this value will cause the material to be reconstructed, so be
+     * aware of performance if using this reactively. Also note that when using this, THREE expects
+     * lighting-related uniforms to be defined on your material; you can use something like the
+     * following to include them:
+     * ```
+     * uniforms: THREE.UniformsUtils.merge([
+     *    THREE.UniformsLib['lights'],
+     *    { other uniforms... }
+     * ]),
+     * ```
+     */
+    lights: { type: boolean, default: false },
+    /**
+     * Controls wireframe thickness. Due to limitations of the OpenGL Core Profile with the WebGL
+     * renderer on most platforms `linewidth` will always be 1 regardless of the set value.
+     */
+    linewidth: { type: number, default: 1.0 },
+    /** Define whether the material is rendered with flat shading. */
+    flatShading: { type: boolean, default: false },
+    /**
+     * An object of the form:
+     * ```
+     * { "uniform1": { value: 1.0 }, "uniform2": { value: 2 } }
+     * ```
+     * specifying the uniforms to be passed to the shader code; keys are uniform names, values are
+     * definitions of the form
+     * ```
+     * { value: 1.0 }
+     * ```
+     * where value is the value of the uniform. Names must match the name of the uniform, as defined
+     * in the GLSL code. Note that uniforms are refreshed on every frame, so updating the value of
+     * the uniform will immediately update the value available to the GLSL code. */
+    uniforms: { type: Object, default: () => ({}) },
+    /**
+     * Vertex shader GLSL code. This is the actual code for the shader.
+     * @default The default vertex shader provided by three.js
+     */
+    vertexShader: { type: string },
+    /**
+     * Render geometry as wireframe (using `GL_LINES` instead of `GL_TRIANGLES`).
+     */
+    wireframe: { type: boolean, default: false },
+    /**
+     * Controls wireframe thickness. Due to limitations of the OpenGL Core Profile with the WebGL
+     * renderer on most platforms `linewidth` will always be 1 regardless of the set value.
+     */
+    wireframeLinewidth: { type: number, default: 1.0 },
+  },
+
+  computed: {
+    inst() {
+      return new ShaderMaterial({
+        fog: this.fog,
+        lights: this.lights,
+      });
+    },
+  },
+
+  watch: {
+    inst: {
+      handler(inst) {
+        Object.assign(inst, {
+          defines: this.defines,
+          fog: this.fog,
+          fragmentShader: this.fragmentShader || inst.fragmentShader,
+          lights: this.lights,
+          linewidth: parseFloat(this.linewidth),
+          flatShading: this.flatShading,
+          uniforms: this.uniforms,
+          vertexShader: this.vertexShader || inst.vertexShader,
+          wireframe: this.wireframe,
+          wireframeLinewidth: parseFloat(this.wireframeLinewidth),
+        });
+      },
+      immediate: true,
+    },
+    defines: {
+      handler(defines) {
+        this.inst.defines = defines;
+        this.inst.needsUpdate = true;
+      },
+      deep: true,
+    },
+    fragmentShader(fragmentShader) {
+      this.inst.fragmentShader = fragmentShader;
+      this.inst.needsUpdate = true;
+    },
+    linewidth(linewidth) {
+      this.inst.linewidth = parseFloat(linewidth);
+    },
+    flatShading(flatShading) {
+      this.inst.flatShading = flatShading;
+      this.inst.needsUpdate = true;
+    },
+    uniforms: {
+      handler(uniforms) {
+        this.inst.uniforms = uniforms;
+      },
+      deep: true,
+    },
+    vertexShader(vertexShader) {
+      this.inst.vertexShader = vertexShader;
+      this.inst.needsUpdate = true;
+    },
+    wireframe(wireframe) {
+      this.inst.wireframe = wireframe;
+    },
+    wireframeLinewidth(wireframeLinewidth) {
+      this.inst.wireframeLinewidth = parseFloat(wireframeLinewidth);
+    },
+  },
+};


Test patch:
diff --git a/test/materials/vgl-shader-material.spec.js b/test/materials/vgl-shader-material.spec.js
new file mode 100644
index 0000000..7cb7c8a
--- /dev/null
+++ b/test/materials/vgl-shader-material.spec.js
@@ -0,0 +1,115 @@
+import Vue from 'vue/dist/vue';
+import { ShaderMaterial } from 'three';
+import { VglShaderMaterial, VglMaterial, VglNamespace } from '../../src';
+
+describe('VglShaderMaterial', () => {
+  let inject;
+  beforeEach(() => {
+    const { vglNamespace } = new Vue({ parent: new Vue(VglNamespace), inject: ['vglNamespace'] });
+    inject = { vglNamespace: { default: vglNamespace } };
+  });
+
+  test('the inst property should be an instance of ShaderMaterial', () => {
+    expect(new (Vue.extend(VglShaderMaterial))({ inject }).inst)
+      .toBeInstanceOf(ShaderMaterial);
+  });
+  test('the component should have common props with VglMaterial', () => {
+    const { $props } = new (Vue.extend(VglMaterial))({ inject });
+    expect(Object.keys(new (Vue.extend(VglShaderMaterial))({ inject }).$props))
+      .toEqual(expect.arrayContaining(Object.keys($props)));
+  });
+  test('the properties of the instance should be specified by props', () => {
+    const { inst } = new (Vue.extend(VglShaderMaterial))({
+      inject,
+      propsData: {
+        defines: { FOO: 1 },
+        fog: true,
+        fragmentShader: 'frag shader',
+        lights: true,
+        linewidth: 2.0,
+        flatShading: true,
+        uniforms: { u: { value: 0.0 } },
+        vertexShader: 'vert shader',
+        wireframe: true,
+        wireframeLinewidth: 3.0,
+      },
+    });
+    expect(inst).toHaveProperty('defines', { FOO: 1 });
+    expect(inst).toHaveProperty('fog', true);
+    expect(inst).toHaveProperty('fragmentShader', 'frag shader');
+    expect(inst).toHaveProperty('lights', true);
+    expect(inst).toHaveProperty('linewidth', 2.0);
+    expect(inst).toHaveProperty('flatShading', true);
+    expect(inst).toHaveProperty('uniforms', { u: { value: 0.0 } });
+    expect(inst).toHaveProperty('vertexShader', 'vert shader');
+    expect(inst).toHaveProperty('wireframe', true);
+    expect(inst).toHaveProperty('wireframeLinewidth', 3.0);
+  });
+  test('the instance sould not be reinstantiated after most props change', async () => {
+    const vm = new (Vue.extend(VglShaderMaterial))({ inject });
+    const { inst } = vm;
+    vm.defines = { FOO: 1 };
+    vm.fragmentShader = 'frag shader';
+    vm.linewidth = 2.0;
+    vm.flatShading = true;
+    vm.uniforms = { u: { value: 0.0 } };
+    vm.vertexShader = 'vert shader';
+    vm.wireframe = true;
+    vm.wireframeLinewidth = 3.0;
+    await vm.$nextTick();
+    expect(inst).toBe(vm.inst);
+  });
+  test('the instance sould be reinstantiated after fog/lights change', async () => {
+    const vm = new (Vue.extend(VglShaderMaterial))({ inject });
+    const { inst } = vm;
+    vm.fog = true;
+    vm.lights = true;
+    await vm.$nextTick();
+    expect(inst).not.toBe(vm.inst);
+  });
+  test('the properties of the instance should change after props change', async () => {
+    const vm = new (Vue.extend(VglShaderMaterial))({ inject });
+    vm.defines = { FOO: 1 };
+    vm.fragmentShader = 'frag shader';
+    vm.linewidth = 2.0;
+    vm.flatShading = true;
+    vm.uniforms = { u: { value: 0.0 } };
+    vm.vertexShader = 'vert shader';
+    vm.wireframe = true;
+    vm.wireframeLinewidth = 3.0;
+    await vm.$nextTick();
+    expect(vm.inst).toHaveProperty('defines', { FOO: 1 });
+    expect(vm.inst).toHaveProperty('fragmentShader', 'frag shader');
+    expect(vm.inst).toHaveProperty('linewidth', 2.0);
+    expect(vm.inst).toHaveProperty('flatShading', true);
+    expect(vm.inst).toHaveProperty('uniforms', { u: { value: 0.0 } });
+    expect(vm.inst).toHaveProperty('vertexShader', 'vert shader');
+    expect(vm.inst).toHaveProperty('wireframe', true);
+    expect(vm.inst).toHaveProperty('wireframeLinewidth', 3.0);
+  });
+  test('the properties of the instance should be defaults without props', () => {
+    const { inst } = new (Vue.extend(VglShaderMaterial))({ inject });
+    const {
+      defines,
+      fog,
+      fragmentShader,
+      lights,
+      linewidth,
+      flatShading,
+      uniforms,
+      vertexShader,
+      wireframe,
+      wireframeLinewidth,
+    } = new ShaderMaterial();
+    expect(inst).toHaveProperty('defines', defines);
+    expect(inst).toHaveProperty('fog', fog);
+    expect(inst).toHaveProperty('fragmentShader', fragmentShader);
+    expect(inst).toHaveProperty('lights', lights);
+    expect(inst).toHaveProperty('linewidth', linewidth);
+    expect(inst).toHaveProperty('flatShading', flatShading);
+    expect(inst).toHaveProperty('uniforms', uniforms);
+    expect(inst).toHaveProperty('vertexShader', vertexShader);
+    expect(inst).toHaveProperty('wireframe', wireframe);
+    expect(inst).toHaveProperty('wireframeLinewidth', wireframeLinewidth);
+  });
+});