Search code examples
javascripttypescriptthree.jsreact-three-fiber

How can I generate Three.js types based on frontend selection?


I'm creating a simple browser app for editing three.js shaders using react-three-fiber, and I want to allow the user to add in additional uniforms to the ShaderMaterial. I'm not exposing the underlying JS, so I'd like to offer a dropdown of some sort to allow the user to select a new uniform that they can add and give values to (such as an input element that takes in an image for later usage as a Sampler2D).

My question is: is there a way to do this without creating a giant list of aliases for types for my frontend?

My current approach is creating an array of possible types that gets rendered into React for people to click on, etc., and then passing the string value of the type they chose and running it through a giant switch statement that figures out what new material to add, which seems like a pretty bad approach. I'm wondering if there's a better way that leverages some sort of inference or some other functionality within Three.js.

I pass in the array of js objects created by user inputs, which then is run through this to return an object of materials. (side note: I'm not sure if forEach is better compared with map here, since I'm not using a returned modified array)

import * as THREE from 'three';

// determines the proper THREE.JS type to allocate
export function buildUniforms(uniforms: Uniform[]) {
  console.log("Building the Uniforms");
  const transformedUniforms = {};

  uniforms.map((un) => {
    console.log(un);
    let value;

    switch (un.type) {
      case "Vector3":
        value = new THREE.Vector3(un.value[0], un.value[1], un.value[2]);
        break;
      case "Vector2":
        value = new THREE.Vector2(un.value[0], un.value[1]);
        break;
      // and so on
    }

    transformedUniforms[`${un.title}`] = {
      value: value
    };
  });

  return transformedUniforms;
}

export interface Uniform {
  title: string;
  type: string;
  value: any;
}

Solution

  • What you want is a union type where You have this:

    type Uniform =
      | { title: string, type: 'Vector2', value: [x?: number, y?: number]
      | { title: string, type: 'Vector3', value: [x?: number, y?: number, z?: number]
    

    Then you could just do:

    const value = new THREE[uniform.type](...uniform.value)
    

    And typescript knows that for any value of uniform.type and uniform.value are linked. But we don't want to hard code that either.

    So then let's derive the types we need from three.js and the constructors it provides.

    First, we need a list of constructors that are opted into this. You probably don't want to allow instantiation of any property of the THREE object, you just want the ones that are useful in shaders.

    type UniformKey = 'Vector2' | 'Vector3' // | 'OtherTypeHere' that you add later
    

    And now we can make a type that distributes over those options and resolves to a union of objet types that provide each case of parameter pairing.

    export type Uniform = {
      [K in UniformKey]: {
        title: string;
        type: K;
        value: ConstructorParameters<typeof THREE[K]>;
      }
    }[UniformKey]
    

    Which Typescript resolves to this type:

    type Uniform = {
        title: string;
        type: "Vector2";
        value: [x?: number | undefined, y?: number | undefined];
    } | {
        title: string;
        type: "Vector3";
        value: [x?: number | undefined, y?: number | undefined, z?: number | undefined];
    }
    

    And now this works fine:

    declare const uniforms: Uniform[]
    for (const uniform in uniforms) {
      const value = new THREE[uniform.type](...uniform.value)
      console.log(value)
    }
    

    And the only thing you have to change to add more constructors that you want to support is to add their names to the UniformKey type.

    See Playground