Repository: playcanvas/engine
Pull request #6831 (2024-07-18 14:56:23)

[BREAKING] Implementation of ShaderMaterial - Material to be used with custom shaders
This PR changes the way a material with custom shader gets created.

BEFORE
```
// create shader
const shader = pc.createShaderFromCode(app.graphicsDevice, 
        files['shader.vert'], files['shader.frag'], 'myShader', { aPosition: pc.SEMANTIC_POSITION });

// create material
const material = new pc.Material();
material.shader = shader;
```

NOW
```
const shaderDesc = {
        uniqueName: 'DecalShader',
        vertexCode: files['shader.vert'],
        fragmentCode: files['shader.frag'],
        attributes: { aPosition: pc.SEMANTIC_POSITION }
};
const decalMaterial = new pc.ShaderMaterial(shaderDesc);
```

**Advantage:** Instead of creating the shader at this time, only the building blocks for the shader are assigned to the material. This allows the engine to create the shader at a later time, when it has more context. This allows for few features:
- (DONE) The engine can create multiple shader variations, as it does with StandardMaterial. Currently, a define with the shader pass name is added to the shader, and this allows the shader code to generate different code for forward pass, different for picker pass, shadows, or user defined passes. Simply use `#ifdef ..#endif` in the supplied shader to provide different code for different pass.

example fragment shader:
```
// when the shader for a pass needed, we inject a define at the start of the shader, and compile it. Inserted line:
#define FORWARD_PASS      // for the forward pass
#define MYCUSTOMNAME_PASS   // for custom pass enabled on the camera

// inside your part of the shader, you can do
#ifdef MYCUSTOMNAME_PASS
    // do something special
#endif
```

- (FUTURE) - We can expose a list of DEFINES that you can specify on an instance of ShaderMaterial, and under the hood we'll compile a different version of it as needed. For example, you can do something like this (API is not finalized): `materialInstance.setDefine('SNOW', true);`
and handle it inside the shader using `#ifdef SNOW`
It will solve this: https://github.com/playcanvas/engine/issues/2962

- (NEAR FUTURE) - allow your shader to automatically handle different output encoding. Currently most shaders encode to sRGB space, and you have no control over this. This makes the shader incompatible with HDR rendering, where a linear output is expected. We'll have a build-in define for this, allowing you to handle both cases in a single shader, and appropriate variant will be used based on the RenderTarget it is rendered to.



Patch:
diff --git a/examples/src/examples/compute/particles.example.mjs b/examples/src/examples/compute/particles.example.mjs
index 62e2575dc..60d70d0f7 100644
--- a/examples/src/examples/compute/particles.example.mjs
+++ b/examples/src/examples/compute/particles.example.mjs
@@ -206,15 +206,16 @@ assetListLoader.load(() => {
 
     // ------- Particle rendering -------
 
-    // use WGSL shader for rendering as GLSL does not have access to storage buffers
+    // material to render the particles using WGSL shader as GLSL does not have access to storage buffers
     const shaderSource = files['shader-shared.wgsl'] + files['shader-rendering.wgsl'];
-    const shaderDefinition = {
-        vshader: shaderSource,
-        fshader: shaderSource,
+    const material = new pc.ShaderMaterial({
+        uniqueName: 'ParticleRenderShader',
+        vertexCode: shaderSource,
+        fragmentCode: shaderSource,
         shaderLanguage: pc.SHADERLANGUAGE_WGSL,
 
         // For now WGSL shaders need to provide their own bind group formats as they aren't processed.
-        // This has to match the structs in the shader.
+        // This has to match the ub_mesh struct in the shader.
         meshUniformBufferFormat: new pc.UniformBufferFormat(app.graphicsDevice, [
             new pc.UniformFormat('matrix_model', pc.UNIFORMTYPE_MAT4)
         ]),
@@ -222,12 +223,7 @@ assetListLoader.load(() => {
             // particle storage buffer in read-only mode
             new pc.BindStorageBufferFormat('particles', pc.SHADERSTAGE_VERTEX | pc.SHADERSTAGE_FRAGMENT, true)
         ])
-    };
-
-    // material to render the particles
-    const material = new pc.Material();
-    material.name = 'ParticleRenderingMaterial';
-    material.shader = new pc.Shader(app.graphicsDevice, shaderDefinition);
+    });
 
     // index buffer - two triangles (6 indices) per particle using 4 vertices
     const indices = new Uint32Array(numParticles * 6);
diff --git a/examples/src/examples/graphics/grab-pass.example.mjs b/examples/src/examples/graphics/grab-pass.example.mjs
index 583bdd481..29de6b087 100644
--- a/examples/src/examples/graphics/grab-pass.example.mjs
+++ b/examples/src/examples/graphics/grab-pass.example.mjs
@@ -129,11 +129,12 @@ assetListLoader.load(() => {
     glass.render.castShadows = false;
     glass.render.receiveShadows = false;
 
-    const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'myShader');
-
     // reflection material using the shader
-    const refractionMaterial = new pc.Material();
-    refractionMaterial.shader = shader;
+    const refractionMaterial = new pc.ShaderMaterial({
+        uniqueName: 'RefractionShader',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag']
+    });
     glass.render.material = refractionMaterial;
 
     // set an offset map on the material
diff --git a/examples/src/examples/graphics/ground-fog.example.mjs b/examples/src/examples/graphics/ground-fog.example.mjs
index a1122965a..210c342b3 100644
--- a/examples/src/examples/graphics/ground-fog.example.mjs
+++ b/examples/src/examples/graphics/ground-fog.example.mjs
@@ -124,16 +124,12 @@ assetListLoader.load(() => {
     app.root.addChild(dirLight);
     dirLight.setLocalEulerAngles(45, 350, 20);
 
-    // create a custom fog shader
-    // @ts-ignore
-    const vertex = `#define VERTEXSHADER\n` + pc.shaderChunks.screenDepthPS + files['shader.vert'];
-    // @ts-ignore
-    const fragment = pc.shaderChunks.screenDepthPS + files['shader.frag'];
-    const shader = pc.createShaderFromCode(app.graphicsDevice, vertex, fragment, 'GroundFogShader');
-
-    // and set up a material using this shader
-    const material = new pc.Material();
-    material.shader = shader;
+    // Create a new material with a fog shader
+    const material = new pc.ShaderMaterial({
+        uniqueName: 'GroundFogShader',
+        vertexCode: `#define VERTEXSHADER\n` + pc.shaderChunks.screenDepthPS + files['shader.vert'],
+        fragmentCode: pc.shaderChunks.screenDepthPS + files['shader.frag']
+    });
     material.setParameter('uTexture', assets.texture.resource);
     material.depthWrite = false;
     material.blendType = pc.BLEND_NORMAL;
diff --git a/examples/src/examples/graphics/paint-mesh.example.mjs b/examples/src/examples/graphics/paint-mesh.example.mjs
index 1549f1cab..f3e25a381 100644
--- a/examples/src/examples/graphics/paint-mesh.example.mjs
+++ b/examples/src/examples/graphics/paint-mesh.example.mjs
@@ -129,16 +129,17 @@ assetListLoader.load(() => {
     const meshEntity = createHighQualitySphere(material, [worldLayer.id]);
     meshEntity.setLocalScale(15, 15, 15);
 
-    // Create the shader from the vertex and fragment shaders
-    const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'myShader', {
-        aPosition: pc.SEMANTIC_POSITION,
-        aUv0: pc.SEMANTIC_TEXCOORD0
+    // Create a decal material with a custom shader
+    const decalMaterial = new pc.ShaderMaterial({
+        uniqueName: 'DecalShader',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag'],
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aUv0: pc.SEMANTIC_TEXCOORD0
+        }
     });
-
-    // Create a decal material with the new shader
-    const decalMaterial = new pc.Material();
     decalMaterial.cull = pc.CULLFACE_NONE;
-    decalMaterial.shader = shader;
     decalMaterial.blendType = pc.BLEND_NORMAL;
     decalMaterial.setParameter('uDecalMap', assets.decal.resource);
 
diff --git a/examples/src/examples/graphics/point-cloud-simulation.example.mjs b/examples/src/examples/graphics/point-cloud-simulation.example.mjs
index b31d639b8..4cf9d6dc2 100644
--- a/examples/src/examples/graphics/point-cloud-simulation.example.mjs
+++ b/examples/src/examples/graphics/point-cloud-simulation.example.mjs
@@ -83,15 +83,17 @@ updateMesh(mesh);
 // set large bounding box so we don't need to update it each frame
 mesh.aabb = new pc.BoundingBox(new pc.Vec3(0, 0, 0), new pc.Vec3(15, 15, 15));
 
-// Create the shader from the vertex and fragment shaders
-const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'myShader', {
-    aPosition: pc.SEMANTIC_POSITION,
-    aUv0: pc.SEMANTIC_TEXCOORD0
+// Create a new material with a custom shader
+const material = new pc.ShaderMaterial({
+    uniqueName: 'MyShader',
+    vertexCode: files['shader.vert'],
+    fragmentCode: files['shader.frag'],
+    attributes: {
+        aPosition: pc.SEMANTIC_POSITION,
+        aUv0: pc.SEMANTIC_TEXCOORD0
+    }
 });
 
-// Create a new material with the new shader and additive alpha blending
-const material = new pc.Material();
-material.shader = shader;
 material.blendType = pc.BLEND_ADDITIVEALPHA;
 material.depthWrite = false;
 
diff --git a/examples/src/examples/graphics/point-cloud.example.mjs b/examples/src/examples/graphics/point-cloud.example.mjs
index 9e83f35fb..362ea5349 100644
--- a/examples/src/examples/graphics/point-cloud.example.mjs
+++ b/examples/src/examples/graphics/point-cloud.example.mjs
@@ -55,16 +55,17 @@ assetListLoader.load(() => {
     const entity = assets.statue.resource.instantiateRenderEntity();
     app.root.addChild(entity);
 
-    // Create the shader definition and shader from the vertex and fragment shaders
-    const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'myShader', {
-        aPosition: pc.SEMANTIC_POSITION,
-        aUv0: pc.SEMANTIC_TEXCOORD0
+    // Create a new material with a custom shader
+    const material = new pc.ShaderMaterial({
+        uniqueName: 'MyShader',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag'],
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aUv0: pc.SEMANTIC_TEXCOORD0
+        }
     });
 
-    // Create a new material with the new shader
-    const material = new pc.Material();
-    material.shader = shader;
-
     // find all render components
     const renderComponents = entity.findComponents('render');
 
diff --git a/examples/src/examples/graphics/reflection-planar.example.mjs b/examples/src/examples/graphics/reflection-planar.example.mjs
index 51c8be093..5ef734f81 100644
--- a/examples/src/examples/graphics/reflection-planar.example.mjs
+++ b/examples/src/examples/graphics/reflection-planar.example.mjs
@@ -106,15 +106,17 @@ assetListLoader.load(() => {
     app.scene.layers.insert(excludedLayer, app.scene.layers.getTransparentIndex(worldLayer) + 1);
 
     // Create the shader from the vertex and fragment shaders
-    const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'myShader', {
-        aPosition: pc.SEMANTIC_POSITION,
-        aUv0: pc.SEMANTIC_TEXCOORD0
-    });
-
     // reflective ground
     // This is in the excluded layer so it does not render into reflection texture
-    const groundMaterial = new pc.Material();
-    groundMaterial.shader = shader;
+    const groundMaterial = new pc.ShaderMaterial({
+        uniqueName: 'MyShader',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag'],
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aUv0: pc.SEMANTIC_TEXCOORD0
+        }
+    });
     createPrimitive(
         'plane',
         new pc.Vec3(0, 0, 0),
diff --git a/examples/src/examples/graphics/shader-burn.example.mjs b/examples/src/examples/graphics/shader-burn.example.mjs
index 2f9db2598..f2c864b97 100644
--- a/examples/src/examples/graphics/shader-burn.example.mjs
+++ b/examples/src/examples/graphics/shader-burn.example.mjs
@@ -68,15 +68,16 @@ assetListLoader.load(() => {
     app.root.addChild(camera);
     app.root.addChild(light);
 
-    // Create the shader from the vertex and fragment shaders
-    const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'myShader', {
-        aPosition: pc.SEMANTIC_POSITION,
-        aUv0: pc.SEMANTIC_TEXCOORD0
+    // Create a new material with the custom shader
+    const material = new pc.ShaderMaterial({
+        uniqueName: 'burn',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag'],
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aUv0: pc.SEMANTIC_TEXCOORD0
+        }
     });
-
-    // Create a new material with the new shader
-    const material = new pc.Material();
-    material.shader = shader;
     material.setParameter('uHeightMap', assets.clouds.resource);
 
     // create a hierarchy of entities with render components, representing the statue model
diff --git a/examples/src/examples/graphics/shader-toon.example.mjs b/examples/src/examples/graphics/shader-toon.example.mjs
index a03aa3c36..a41fb9481 100644
--- a/examples/src/examples/graphics/shader-toon.example.mjs
+++ b/examples/src/examples/graphics/shader-toon.example.mjs
@@ -67,17 +67,18 @@ assetListLoader.load(() => {
     app.root.addChild(camera);
     app.root.addChild(light);
 
-    // Create the shader from the vertex and fragment shaders
-    const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'myShader', {
-        aPosition: pc.SEMANTIC_POSITION,
-        aNormal: pc.SEMANTIC_NORMAL,
-        aUv: pc.SEMANTIC_TEXCOORD0
+    // Create a new material with a custom shader
+    const material = new pc.ShaderMaterial({
+        uniqueName: 'toon',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag'],
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aNormal: pc.SEMANTIC_NORMAL,
+            aUv: pc.SEMANTIC_TEXCOORD0
+        }
     });
 
-    // Create a new material with the new shader
-    const material = new pc.Material();
-    material.shader = shader;
-
     // create a hierarchy of entities with render components, representing the statue model
     const entity = assets.statue.resource.instantiateRenderEntity();
     app.root.addChild(entity);
diff --git a/examples/src/examples/graphics/shader-wobble.example.mjs b/examples/src/examples/graphics/shader-wobble.example.mjs
index 1334e7117..b3824db67 100644
--- a/examples/src/examples/graphics/shader-wobble.example.mjs
+++ b/examples/src/examples/graphics/shader-wobble.example.mjs
@@ -67,16 +67,17 @@ assetListLoader.load(() => {
     app.root.addChild(camera);
     app.root.addChild(light);
 
-    // Create the shader from the vertex and fragment shaders
-    const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'myShader', {
-        aPosition: pc.SEMANTIC_POSITION,
-        aUv0: pc.SEMANTIC_TEXCOORD0
+    // Create a new material with a custom shader
+    const material = new pc.ShaderMaterial({
+        uniqueName: 'wobble',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag'],
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aUv0: pc.SEMANTIC_TEXCOORD0
+        }
     });
 
-    // Create a new material with the new shader
-    const material = new pc.Material();
-    material.shader = shader;
-
     // create a hierarchy of entities with render components, representing the statue model
     const entity = assets.statue.resource.instantiateRenderEntity();
     app.root.addChild(entity);
diff --git a/examples/src/examples/graphics/texture-array.example.mjs b/examples/src/examples/graphics/texture-array.example.mjs
index 344ba0463..eeb359d18 100644
--- a/examples/src/examples/graphics/texture-array.example.mjs
+++ b/examples/src/examples/graphics/texture-array.example.mjs
@@ -110,25 +110,6 @@ assetListLoader.load(() => {
     });
     light.setLocalEulerAngles(45, 0, 45);
 
-    // Create the shader definition and shader from the vertex and fragment shaders
-    const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'myShader', {
-        aPosition: pc.SEMANTIC_POSITION,
-        aUv0: pc.SEMANTIC_TEXCOORD0,
-        aNormal: pc.SEMANTIC_NORMAL
-    });
-
-    const shaderGround = pc.createShaderFromCode(
-        app.graphicsDevice,
-        files['shader.vert'],
-        files['ground.frag'],
-        'groundsShader',
-        {
-            aPosition: pc.SEMANTIC_POSITION,
-            aUv0: pc.SEMANTIC_TEXCOORD0,
-            aNormal: pc.SEMANTIC_NORMAL
-        }
-    );
-
     const textureArrayOptions = {
         name: 'textureArrayImages',
         format: pc.PIXELFORMAT_RGBA8,
@@ -167,14 +148,30 @@ assetListLoader.load(() => {
     const mipmapTextureArray = new pc.Texture(app.graphicsDevice, textureArrayOptions);
 
     // Create a new material with the new shader
-    const material = new pc.Material();
-    material.shader = shader;
+    const material = new pc.ShaderMaterial({
+        uniqueName: 'MyShader',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag'],
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aUv0: pc.SEMANTIC_TEXCOORD0,
+            aNormal: pc.SEMANTIC_NORMAL
+        }
+    });
     material.setParameter('uDiffuseMap', textureArray);
     material.update();
 
     // Create a another material with the new shader
-    const groundMaterial = new pc.Material();
-    groundMaterial.shader = shaderGround;
+    const groundMaterial = new pc.ShaderMaterial({
+        uniqueName: 'MyShaderGround',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['ground.frag'],
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aUv0: pc.SEMANTIC_TEXCOORD0,
+            aNormal: pc.SEMANTIC_NORMAL
+        }
+    });
     groundMaterial.cull = pc.CULLFACE_NONE;
     groundMaterial.setParameter('uDiffuseMap', textureArray);
     groundMaterial.update();
diff --git a/examples/src/examples/graphics/transform-feedback.example.mjs b/examples/src/examples/graphics/transform-feedback.example.mjs
index 101952e5c..dec30a2bf 100644
--- a/examples/src/examples/graphics/transform-feedback.example.mjs
+++ b/examples/src/examples/graphics/transform-feedback.example.mjs
@@ -118,16 +118,14 @@ assetListLoader.load(() => {
         // set large bounding box so we don't need to update it each frame
         mesh.aabb = new pc.BoundingBox(new pc.Vec3(0, 0, 0), new pc.Vec3(100, 100, 100));
 
-        // Create the shader from the vertex and fragment shaders which is used to render point sprites
-        shader = new pc.Shader(app.graphicsDevice, {
-            attributes: { aPosition: pc.SEMANTIC_POSITION },
-            vshader: files['shaderCloud.vert'],
-            fshader: files['shaderCloud.frag']
+        // Create the material from the vertex and fragment shaders which is used to render point sprites
+        const material = new pc.ShaderMaterial({
+            uniqueName: 'TransformFeerback',
+            vertexCode: files['shaderCloud.vert'],
+            fragmentCode: files['shaderCloud.frag'],
+            attributes: { aPosition: pc.SEMANTIC_POSITION }
         });
 
-        // Create a new material with the new shader and additive alpha blending
-        const material = new pc.Material();
-        material.shader = shader;
         material.blendType = pc.BLEND_ADDITIVEALPHA;
         material.depthWrite = false;
 
diff --git a/examples/src/examples/graphics/wgsl-shader.example.mjs b/examples/src/examples/graphics/wgsl-shader.example.mjs
index 7aa834be0..dfde9dfc6 100644
--- a/examples/src/examples/graphics/wgsl-shader.example.mjs
+++ b/examples/src/examples/graphics/wgsl-shader.example.mjs
@@ -44,9 +44,10 @@ app.on('destroy', () => {
     window.removeEventListener('resize', resize);
 });
 
-const shaderDefinition = {
-    vshader: files['shader.wgsl'],
-    fshader: files['shader.wgsl'],
+const material = new pc.ShaderMaterial({
+    uniqueName: 'MyWGSLShader',
+    vertexCode: files['shader.wgsl'],
+    fragmentCode: files['shader.wgsl'],
     shaderLanguage: pc.SHADERLANGUAGE_WGSL,
 
     // For now WGSL shaders need to provide their own bind group formats as they aren't processed.
@@ -56,11 +57,7 @@ const shaderDefinition = {
         new pc.UniformFormat('amount', pc.UNIFORMTYPE_FLOAT)
     ]),
     meshBindGroupFormat: new pc.BindGroupFormat(app.graphicsDevice, [])
-};
-const shader = new pc.Shader(app.graphicsDevice, shaderDefinition);
-
-const material = new pc.Material();
-material.shader = shader;
+});
 
 // create box entity
 const box = new pc.Entity('cube');
diff --git a/examples/src/examples/loaders/loaders-gl.example.mjs b/examples/src/examples/loaders/loaders-gl.example.mjs
index cf2866db0..01ecd3981 100644
--- a/examples/src/examples/loaders/loaders-gl.example.mjs
+++ b/examples/src/examples/loaders/loaders-gl.example.mjs
@@ -69,14 +69,16 @@ async function loadModel(url) {
     mesh.setColors32(colors32);
     mesh.update(pc.PRIMITIVE_POINTS);
 
-    const shader = pc.createShaderFromCode(app.graphicsDevice, files['shader.vert'], files['shader.frag'], 'MyShader', {
-        aPosition: pc.SEMANTIC_POSITION,
-        aColor: pc.SEMANTIC_COLOR
-    });
-
     // create material using the shader
-    const material = new pc.Material();
-    material.shader = shader;
+    const material = new pc.ShaderMaterial({
+        uniqueName: 'MyShader',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag'],
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aColor: pc.SEMANTIC_COLOR
+        }
+    });
     material.blendType = pc.BLENDMODE_ONE_MINUS_DST_ALPHA;
     material.cull = pc.CULLFACE_NONE;
 
diff --git a/examples/src/examples/user-interface/custom-shader.example.mjs b/examples/src/examples/user-interface/custom-shader.example.mjs
index 7515a3146..74ed45f24 100644
--- a/examples/src/examples/user-interface/custom-shader.example.mjs
+++ b/examples/src/examples/user-interface/custom-shader.example.mjs
@@ -69,21 +69,16 @@ assetListLoader.load(() => {
     });
     app.root.addChild(screen);
 
-    // Create the shader from the vertex and fragment shader
-    const shader = pc.createShaderFromCode(
-        app.graphicsDevice,
-        files['shader.vert'],
-        files['shader.frag'],
-        'myUIShader',
-        {
+    // Create a new material with the new shader and additive alpha blending
+    const material = new pc.ShaderMaterial({
+        uniqueName: 'myUIShader',
+        vertexCode: files['shader.vert'],
+        fragmentCode: files['shader.frag'],
+        attributes: {
             vertex_position: pc.SEMANTIC_POSITION,
             vertex_texCoord0: pc.SEMANTIC_TEXCOORD0
         }
-    );
-
-    // Create a new material with the new shader and additive alpha blending
-    const material = new pc.Material();
-    material.shader = shader;
+    });
     material.blendType = pc.BLEND_ADDITIVEALPHA;
     material.depthWrite = true;
     material.setParameter('uDiffuseMap', assets.playcanvas.resource);
diff --git a/examples/src/examples/xr/ar-camera-depth.example.mjs b/examples/src/examples/xr/ar-camera-depth.example.mjs
index 9f51745a6..52665b278 100644
--- a/examples/src/examples/xr/ar-camera-depth.example.mjs
+++ b/examples/src/examples/xr/ar-camera-depth.example.mjs
@@ -111,7 +111,7 @@ const fragShader = /* glsl */ `
         gl_FragColor = vec4(depth, depth, depth, 1.0);
     }`;
 
-const materialDepth = new pc.Material();
+const materialDepth = new pc.ShaderMaterial();
 
 /**
  * @param {boolean} array - If the depth information uses array texture.
@@ -130,11 +130,16 @@ const updateShader = (array, float) => {
 
     if (shaderDepthArray) frag = '#define XRDEPTH_FLOAT\n' + frag;
 
-    materialDepth.shader = pc.createShaderFromCode(app.graphicsDevice, vertShader, frag, key, {
-        aPosition: pc.SEMANTIC_POSITION,
-        aUv0: pc.SEMANTIC_TEXCOORD0
-    });
-    materialDepth.clearVariants();
+    materialDepth.shaderDesc = {
+        uniqueName: key,
+        vertexCode: vertShader,
+        fragmentCode: frag,
+        attributes: {
+            aPosition: pc.SEMANTIC_POSITION,
+            aUv0: pc.SEMANTIC_TEXCOORD0
+        }
+    };
+
     materialDepth.update();
 };
 
diff --git a/src/core/preprocessor.js b/src/core/preprocessor.js
index 017277c88..92dff6793 100644
--- a/src/core/preprocessor.js
+++ b/src/core/preprocessor.js
@@ -50,7 +50,7 @@ class Preprocessor {
     static run(source, includes = new Map(), stripUnusedColorAttachments = false) {
 
         // strips comments, handles // and many cases of /*
-        source = source.replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '$1');
+        source = this.stripComments(source);
 
         // right trim each line
         source = source.split(/\r?\n/)
@@ -89,6 +89,9 @@ class Preprocessor {
             }
         });
 
+        // strip comments again after the includes have been resolved
+        source = this.stripComments(source);
+
         // remove empty lines
         source = this.RemoveEmptyLines(source);
 
@@ -98,6 +101,10 @@ class Preprocessor {
         return source;
     }
 
+    static stripComments(source) {
+        return source.replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '$1');
+    }
+
     static processArraySize(source, intDefines) {
 
         if (source !== null) {
diff --git a/src/deprecated/deprecated.js b/src/deprecated/deprecated.js
index bd0bceb78..e42adb425 100644
--- a/src/deprecated/deprecated.js
+++ b/src/deprecated/deprecated.js
@@ -611,25 +611,15 @@ GraphNode.prototype.setName = function (name) {
     this.name = name;
 };
 
-Material.prototype.getName = function () {
-    Debug.deprecated('pc.Material#getName is deprecated. Use pc.Material#name instead.');
-    return this.name;
-};
-
-Material.prototype.setName = function (name) {
-    Debug.deprecated('pc.Material#setName is deprecated. Use pc.Material#name instead.');
-    this.name = name;
-};
-
-Material.prototype.getShader = function () {
-    Debug.deprecated('pc.Material#getShader is deprecated. Use pc.Material#shader instead.');
-    return this.shader;
-};
-
-Material.prototype.setShader = function (shader) {
-    Debug.deprecated('pc.Material#setShader is deprecated. Use pc.Material#shader instead.');
-    this.shader = shader;
-};
+Object.defineProperty(Material.prototype, 'shader', {
+    set: function (value) {
+        Debug.deprecated(`pc.Material#shader is deprecated, use pc.ShaderMaterial instead.`);
+    },
+    get: function () {
+        Debug.deprecated(`pc.Material#shader is deprecated, use pc.ShaderMaterial instead.`);
+        return null;
+    }
+});
 
 // Note: this is used by the Editor
 Object.defineProperty(Material.prototype, 'blend', {
diff --git a/src/extras/gizmo/axis-shapes.js b/src/extras/gizmo/axis-shapes.js
index cacf99056..88df26998 100644
--- a/src/extras/gizmo/axis-shapes.js
+++ b/src/extras/gizmo/axis-shapes.js
@@ -1,12 +1,11 @@
 import { Color } from '../../core/math/color.js';
 import { Vec3 } from '../../core/math/vec3.js';
 import { Quat } from '../../core/math/quat.js';
-import { Material } from '../../scene/materials/material.js';
+import { ShaderMaterial } from '../../scene/materials/shader-material.js';
 import { MeshInstance } from '../../scene/mesh-instance.js';
 import { Entity } from '../../framework/entity.js';
 import { CULLFACE_NONE, CULLFACE_BACK, SEMANTIC_POSITION, SEMANTIC_COLOR } from '../../platform/graphics/constants.js';
 import { BLEND_NORMAL } from '../../scene/constants.js';
-import { createShaderFromCode } from '../../scene/shader-lib/utils.js';
 
 import { COLOR_GRAY } from './color.js';
 import { TriData } from './tri-data.js';
@@ -201,13 +200,15 @@ class AxisShape {
     }
 
     _addRenderMeshes(entity, meshes) {
-        const shader = createShaderFromCode(this.device, SHADER.vert, SHADER.frag, 'axis-shape', {
-            vertex_position: SEMANTIC_POSITION,
-            vertex_color: SEMANTIC_COLOR
+        const material = new ShaderMaterial({
+            uniqueName: 'axis-shape',
+            vertexCode: SHADER.vert,
+            fragmentCode: SHADER.frag,
+            attributes: {
+                vertex_position: SEMANTIC_POSITION,
+                vertex_color: SEMANTIC_COLOR
+            }
         });
-
-        const material = new Material();
-        material.shader = shader;
         material.cull = this._cull;
         material.blendType = BLEND_NORMAL;
         material.update();
diff --git a/src/extras/mini-stats/render2d.js b/src/extras/mini-stats/render2d.js
index 5aac8ea23..5c775ad40 100644
--- a/src/extras/mini-stats/render2d.js
+++ b/src/extras/mini-stats/render2d.js
@@ -13,12 +13,11 @@ import { DepthState } from '../../platform/graphics/depth-state.js';
 import { BlendState } from '../../platform/graphics/blend-state.js';
 import { GraphNode } from '../../scene/graph-node.js';
 import { MeshInstance } from '../../scene/mesh-instance.js';
-import { Material } from '../../scene/materials/material.js';
 import { Mesh } from '../../scene/mesh.js';
 import { IndexBuffer } from '../../platform/graphics/index-buffer.js';
 import { VertexBuffer } from '../../platform/graphics/vertex-buffer.js';
 import { VertexFormat } from '../../platform/graphics/vertex-format.js';
-import { createShaderFromCode } from '../../scene/shader-lib/utils.js';
+import { ShaderMaterial } from '../../scene/materials/shader-material.js';
 
 const vertexShader = /* glsl */ `
 attribute vec3 vertex_position;         // unnormalized xy, word flag
@@ -82,8 +81,6 @@ class Render2d {
             indices[i * 6 + 5] = i * 4 + 3;
         }
 
-        const shader = createShaderFromCode(device, vertexShader, fragmentShader, 'mini-stats');
-
         this.device = device;
         this.buffer = new VertexBuffer(device, format, maxQuads * 4, {
             usage: BUFFER_STREAM
@@ -103,10 +100,13 @@ class Render2d {
         this.mesh.indexBuffer[0] = this.indexBuffer;
         this.mesh.primitive = [this.prim];
 
-        const material = new Material();
+        const material = new ShaderMaterial({
+            uniqueName: 'MiniStats',
+            vertexCode: vertexShader,
+            fragmentCode: fragmentShader
+        });
         this.material = material;
         material.cull = CULLFACE_NONE;
-        material.shader = shader;
         material.depthState = DepthState.NODEPTH;
         material.blendState = new BlendState(true, BLENDEQUATION_ADD, BLENDMODE_SRC_ALPHA, BLENDMODE_ONE_MINUS_SRC_ALPHA,
                                              BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE);
diff --git a/src/framework/app-base.js b/src/framework/app-base.js
index 235913dc1..552278170 100644
--- a/src/framework/app-base.js
+++ b/src/framework/app-base.js
@@ -31,7 +31,7 @@ import { AreaLightLuts } from '../scene/area-light-luts.js';
 import { Layer } from '../scene/layer.js';
 import { LayerComposition } from '../scene/composition/layer-composition.js';
 import { Scene } from '../scene/scene.js';
-import { Material } from '../scene/materials/material.js';
+import { ShaderMaterial } from '../scene/materials/shader-material.js';
 import { StandardMaterial } from '../scene/materials/standard-material.js';
 import { setDefaultMaterial } from '../scene/materials/default-material.js';
 
@@ -61,6 +61,7 @@ import { getApplication, setApplication } from './globals.js';
  * @import { GraphicsDevice } from '../platform/graphics/graphics-device.js'
  * @import { Keyboard } from '../platform/input/keyboard.js'
  * @import { Lightmapper } from './lightmapper/lightmapper.js'
+ * @import { Material } from '../scene/materials/material.js'
  * @import { MeshInstance } from '../scene/mesh-instance.js'
  * @import { Mesh } from '../scene/mesh.js'
  * @import { Mouse } from '../platform/input/mouse.js'
@@ -1768,10 +1769,10 @@ class AppBase extends EventHandler {
         matrix.setTRS(new Vec3(x, y, 0.0), Quat.IDENTITY, new Vec3(width, -height, 0.0));
 
         if (!material) {
-            material = new Material();
+            material = new ShaderMaterial();
             material.cull = CULLFACE_NONE;
             material.setParameter("colorMap", texture);
-            material.shader = filterable ? this.scene.immediate.getTextureShader(texture.encoding) : this.scene.immediate.getUnfilterableTextureShader();
+            material.shaderDesc = filterable ? this.scene.immediate.getTextureShaderDesc(texture.encoding) : this.scene.immediate.getUnfilterableTextureShaderDesc();
             material.update();
         }
 
@@ -1794,9 +1795,9 @@ class AppBase extends EventHandler {
      * @ignore
      */
     drawDepthTexture(x, y, width, height, layer = this.scene.defaultDrawLayer) {
-        const material = new Material();
+        const material = new ShaderMaterial();
         material.cull = CULLFACE_NONE;
-        material.shader = this.scene.immediate.getDepthTextureShader();
+        material.shaderDesc = this.scene.immediate.getDepthTextureShaderDesc();
         material.update();
 
         this.drawTexture(x, y, width, height, null, material, layer);
diff --git a/src/index.js b/src/index.js
index 28e6f1cc8..23f0db798 100644
--- a/src/index.js
+++ b/src/index.js
@@ -123,13 +123,11 @@ export { SoundInstance3d } from './platform/sound/instance3d.js';
 // SCENE
 export * from './scene/constants.js';
 export { drawQuadWithShader, drawTexture } from './scene/graphics/quad-render-utils.js';
-export { BasicMaterial } from './scene/materials/basic-material.js';
 export { Batch } from './scene/batching/batch.js';
 export { BatchGroup } from './scene/batching/batch-group.js';
 export { SkinBatchInstance } from './scene/batching/skin-batch-instance.js';
 export { BatchManager } from './scene/batching/batch-manager.js';
 export { Camera } from './scene/camera.js';
-export { LitMaterial } from './scene/materials/lit-material.js';
 export { WorldClusters } from './scene/lighting/world-clusters.js';
 export { ForwardRenderer } from './scene/renderer/forward-renderer.js';
 export { GraphNode } from './scene/graph-node.js';
@@ -137,7 +135,6 @@ export { Layer } from './scene/layer.js';
 export { LayerComposition } from './scene/composition/layer-composition.js';
 export { Light } from './scene/light.js';
 export { LightingParams } from './scene/lighting/lighting-params.js';
-export { Material } from './scene/materials/material.js';
 export { Mesh } from './scene/mesh.js';
 export { MeshInstance } from './scene/mesh-instance.js';
 export { Model } from './scene/model.js';
@@ -151,8 +148,6 @@ export { ShaderPass } from './scene/shader-pass.js';
 export { Skin } from './scene/skin.js';
 export { SkinInstance } from './scene/skin-instance.js';
 export { Sprite } from './scene/sprite.js';
-export { StandardMaterial } from './scene/materials/standard-material.js';
-export { StandardMaterialOptions } from './scene/materials/standard-material-options.js';
 export { StencilParameters } from './platform/graphics/stencil-parameters.js';
 export { TextureAtlas } from './scene/texture-atlas.js';
 
@@ -165,9 +160,16 @@ export { EnvLighting } from './scene/graphics/env-lighting.js';
 export { PostEffect } from './scene/graphics/post-effect.js';
 export { RenderPassColorGrab } from './scene/graphics/render-pass-color-grab.js';
 export { RenderPassShaderQuad } from './scene/graphics/render-pass-shader-quad.js';
-export { shFromCubemap } from './scene/graphics/prefilter-cubemap.js';
 export { reprojectTexture } from './scene/graphics/reproject-texture.js';
 
+// SCENE / MATERIALS
+export { BasicMaterial } from './scene/materials/basic-material.js';
+export { LitMaterial } from './scene/materials/lit-material.js';
+export { Material } from './scene/materials/material.js';
+export { ShaderMaterial } from './scene/materials/shader-material.js';
+export { StandardMaterial } from './scene/materials/standard-material.js';
+export { StandardMaterialOptions } from './scene/materials/standard-material-options.js';
+
 // SCENE / PROCEDURAL
 export { calculateNormals, calculateTangents } from './scene/geometry/geometry-utils.js';
 export { CapsuleGeometry } from './scene/geometry/capsule-geometry.js';
diff --git a/src/platform/graphics/shader-processor.js b/src/platform/graphics/shader-processor.js
index 2881c3a87..f3e099947 100644
--- a/src/platform/graphics/shader-processor.js
+++ b/src/platform/graphics/shader-processor.js
@@ -384,6 +384,7 @@ class ShaderProcessor {
             if (shaderDefinitionAttributes.hasOwnProperty(name)) {
                 const semantic = shaderDefinitionAttributes[name];
                 const location = semanticToLocation[semantic];
+                Debug.assert(location !== undefined, `Semantic ${semantic} used by the attribute ${name} is not known - make sure it's one of the supported semantics.`);
 
                 Debug.assert(!usedLocations.hasOwnProperty(location),
                              `WARNING: Two vertex attributes are mapped to the same location in a shader: ${usedLocations[location]} and ${semantic}`);
diff --git a/src/platform/graphics/shader-utils.js b/src/platform/graphics/shader-utils.js
index 0e60c569f..709d26259 100644
--- a/src/platform/graphics/shader-utils.js
+++ b/src/platform/graphics/shader-utils.js
@@ -2,7 +2,8 @@ import { Debug } from "../../core/debug.js";
 import {
     SEMANTIC_POSITION, SEMANTIC_NORMAL, SEMANTIC_TANGENT, SEMANTIC_TEXCOORD0, SEMANTIC_TEXCOORD1, SEMANTIC_TEXCOORD2,
     SEMANTIC_TEXCOORD3, SEMANTIC_TEXCOORD4, SEMANTIC_TEXCOORD5, SEMANTIC_TEXCOORD6, SEMANTIC_TEXCOORD7,
-    SEMANTIC_COLOR, SEMANTIC_BLENDINDICES, SEMANTIC_BLENDWEIGHT
+    SEMANTIC_COLOR, SEMANTIC_BLENDINDICES, SEMANTIC_BLENDWEIGHT,
+    SHADERLANGUAGE_WGSL
 } from './constants.js';
 import gles3FS from './shader-chunks/frag/gles3.js';
 import gles3VS from './shader-chunks/vert/gles3.js';
@@ -102,37 +103,47 @@ class ShaderUtils {
         };
 
         const name = options.name ?? 'Untitled';
-
-        // vertex code
-        const vertCode = ShaderUtils.versionCode(device) +
-            getDefines(webgpuVS, gles3VS, true, options) +
-            ShaderUtils.getDefinesCode(options.vertexDefines) +
-            ShaderUtils.precisionCode(device) + '\n' +
-            sharedFS +
-            ShaderUtils.getShaderNameCode(name) +
-            options.vertexCode;
-
-        // fragment code
-        const fragCode = (options.fragmentPreamble || '') +
-            ShaderUtils.versionCode(device) +
-            getDefines(webgpuFS, gles3FS, false, options) +
-            ShaderUtils.getDefinesCode(options.fragmentDefines) +
-            ShaderUtils.precisionCode(device) + '\n' +
-            sharedFS +
-            ShaderUtils.getShaderNameCode(name) +
-            (options.fragmentCode || ShaderUtils.dummyFragmentCode());
-
-        // attributes
-        const attribs = options.attributes ?? ShaderUtils.collectAttributes(options.vertexCode);
+        let vertCode;
+        let fragCode;
+
+        if (options.shaderLanguage === SHADERLANGUAGE_WGSL) {
+
+            vertCode = options.vertexCode;
+            fragCode = options.fragmentCode;
+
+        } else {
+
+            // vertex code
+            vertCode = ShaderUtils.versionCode(device) +
+                getDefines(webgpuVS, gles3VS, true, options) +
+                ShaderUtils.getDefinesCode(options.vertexDefines) +
+                ShaderUtils.precisionCode(device) + '\n' +
+                sharedFS +
+                ShaderUtils.getShaderNameCode(name) +
+                options.vertexCode;
+
+            // fragment code
+            fragCode = (options.fragmentPreamble || '') +
+                ShaderUtils.versionCode(device) +
+                getDefines(webgpuFS, gles3FS, false, options) +
+                ShaderUtils.getDefinesCode(options.fragmentDefines) +
+                ShaderUtils.precisionCode(device) + '\n' +
+                sharedFS +
+                ShaderUtils.getShaderNameCode(name) +
+                (options.fragmentCode || ShaderUtils.dummyFragmentCode());
+        }
 
         return {
             name: name,
-            attributes: attribs,
+            shaderLanguage: options.shaderLanguage,
+            attributes: options.attributes,
             vshader: vertCode,
             vincludes: options.vertexIncludes,
             fincludes: options.fragmentIncludes,
             fshader: fragCode,
-            useTransformFeedback: options.useTransformFeedback
+            useTransformFeedback: options.useTransformFeedback,
+            meshUniformBufferFormat: options.meshUniformBufferFormat,
+            meshBindGroupFormat: options.meshBindGroupFormat
         };
     }
 
@@ -204,20 +215,33 @@ class ShaderUtils {
         let found = vsCode.indexOf("attribute");
         while (found >= 0) {
             if (found > 0 && vsCode[found - 1] === "/") break;
-            const endOfLine = vsCode.indexOf(';', found);
-            const startOfAttribName = vsCode.lastIndexOf(' ', endOfLine);
-            const attribName = vsCode.substring(startOfAttribName + 1, endOfLine);
-
-            // if the attribute already exists in the semantic map
-            if (attribs[attribName]) {
-                Debug.warn(`Attribute [${attribName}] already exists when extracting the attributes from the vertex shader, ignoring.`, { vsCode });
-            } else {
-                const semantic = _attrib2Semantic[attribName];
-                if (semantic !== undefined) {
-                    attribs[attribName] = semantic;
+
+            // skip the 'attribute' word inside the #define which we add to the shader
+            let ignore = false;
+            if (found > 0) {
+                let startOfLine = vsCode.lastIndexOf('\n', found);
+                startOfLine = startOfLine !== -1 ? startOfLine + 1 : 0;
+                const lineStartString = vsCode.substring(startOfLine, found);
+                if (lineStartString.includes('#'))
+                    ignore = true;
+            }
+
+            if (!ignore) {
+                const endOfLine = vsCode.indexOf(';', found);
+                const startOfAttribName = vsCode.lastIndexOf(' ', endOfLine);
+                const attribName = vsCode.substring(startOfAttribName + 1, endOfLine);
+
+                // if the attribute already exists in the semantic map
+                if (attribs[attribName]) {
+                    Debug.warn(`Attribute [${attribName}] already exists when extracting the attributes from the vertex shader, ignoring.`, { vsCode });
                 } else {
-                    attribs[attribName] = "ATTR" + attrs;
-                    attrs++;
+                    const semantic = _attrib2Semantic[attribName];
+                    if (semantic !== undefined) {
+                        attribs[attribName] = semantic;
+                    } else {
+                        attribs[attribName] = "ATTR" + attrs;
+                        attrs++;
+                    }
                 }
             }
 
diff --git a/src/platform/graphics/shader.js b/src/platform/graphics/shader.js
index 60290fe60..d3b5bab01 100644
--- a/src/platform/graphics/shader.js
+++ b/src/platform/graphics/shader.js
@@ -3,6 +3,7 @@ import { Debug } from '../../core/debug.js';
 import { platform } from '../../core/platform.js';
 import { Preprocessor } from '../../core/preprocessor.js';
 import { DebugGraphics } from './debug-graphics.js';
+import { ShaderUtils } from './shader-utils.js';
 
 /**
  * @import { BindGroupFormat } from './bind-group-format.js'
@@ -119,6 +120,9 @@ class Shader {
             // pre-process shader sources
             definition.vshader = Preprocessor.run(definition.vshader, definition.vincludes);
 
+            // if not attributes are specified, try to extract the default names after the shader has been pre-processed
+            definition.attributes ??= ShaderUtils.collectAttributes(definition.vshader);
+
             // Strip unused color attachments from fragment shader.
             // Note: this is only needed for iOS 15 on WebGL2 where there seems to be a bug where color attachments that are not
             // written to generate metal linking errors. This is fixed on iOS 16, and iOS 14 does not support WebGL2.
diff --git a/src/platform/graphics/webgl/webgl-shader.js b/src/platform/graphics/webgl/webgl-shader.js
index da2e2d04c..d88c0d896 100644
--- a/src/platform/graphics/webgl/webgl-shader.js
+++ b/src/platform/graphics/webgl/webgl-shader.js
@@ -146,7 +146,7 @@ class WebglShader {
 
         const definition = shader.definition;
         const attrs = definition.attributes;
-        if (device.isWebGL2 && definition.useTransformFeedback) {
+        if (definition.useTransformFeedback) {
             // Collect all "out_" attributes and use them for output
             const outNames = [];
             for (const attr in attrs) {
diff --git a/src/platform/graphics/webgpu/webgpu-shader.js b/src/platform/graphics/webgpu/webgpu-shader.js
index 772cb8725..6ff1f5685 100644
--- a/src/platform/graphics/webgpu/webgpu-shader.js
+++ b/src/platform/graphics/webgpu/webgpu-shader.js
@@ -65,8 +65,8 @@ class WebgpuShader {
             this._fragmentCode = definition.fshader ?? null;
             this._computeCode = definition.cshader ?? null;
 
-            this.meshUniformBufferFormat = definition.meshUniformBufferFormat;
-            this.meshBindGroupFormat = definition.meshBindGroupFormat;
+            shader.meshUniformBufferFormat = definition.meshUniformBufferFormat;
+            shader.meshBindGroupFormat = definition.meshBindGroupFormat;
 
             this.computeUniformBufferFormats = definition.computeUniformBufferFormats;
             this.computeBindGroupFormat = definition.computeBindGroupFormat;
diff --git a/src/scene/graphics/prefilter-cubemap.js b/src/scene/graphics/prefilter-cubemap.js
deleted file mode 100644
index 94d9c62b7..000000000
--- a/src/scene/graphics/prefilter-cubemap.js
+++ /dev/null
@@ -1,226 +0,0 @@
-import { Debug } from '../../core/debug.js';
-import { Vec3 } from '../../core/math/vec3.js';
-
-import {
-    PIXELFORMAT_RGBA8, TEXTURETYPE_DEFAULT, TEXTURETYPE_RGBM
-} from '../../platform/graphics/constants.js';
-import { createShaderFromCode } from '../shader-lib/utils.js';
-import { drawQuadWithShader } from './quad-render-utils.js';
-import { shaderChunks } from '../shader-lib/chunks/chunks.js';
-import { RenderTarget } from '../../platform/graphics/render-target.js';
-import { Texture } from '../../platform/graphics/texture.js';
-import { BlendState } from '../../platform/graphics/blend-state.js';
-
-// https://seblagarde.wordpress.com/2012/06/10/amd-cubemapgen-for-physically-based-rendering/
-function areaElement(x, y) {
-    return Math.atan2(x * y, Math.sqrt(x * x + y * y + 1));
-}
-
-function texelCoordSolidAngle(u, v, size) {
-    // Scale up to [-1, 1] range (inclusive), offset by 0.5 to point to texel center.
-    let _u = (2.0 * (u + 0.5) / size) - 1.0;
-    let _v = (2.0 * (v + 0.5) / size) - 1.0;
-
-    // fixSeams
-    _u *= 1.0 - 1.0 / size;
-    _v *= 1.0 - 1.0 / size;
-
-    const invResolution = 1.0 / size;
-
-    // U and V are the -1..1 texture coordinate on the current face.
-    // Get projected area for this texel
-    const x0 = _u - invResolution;
-    const y0 = _v - invResolution;
-    const x1 = _u + invResolution;
-    const y1 = _v + invResolution;
-    let solidAngle = areaElement(x0, y0) - areaElement(x0, y1) - areaElement(x1, y0) + areaElement(x1, y1);
-
-    // fixSeams cut
-    if ((u === 0 && v === 0) || (u === size - 1 && v === 0) || (u === 0 && v === size - 1) || (u === size - 1 && v === size - 1)) {
-        solidAngle /= 3;
-    } else if (u === 0 || v === 0 || u === size - 1 || v === size - 1) {
-        solidAngle *= 0.5;
-    }
-
-    return solidAngle;
-}
-
-function shFromCubemap(device, source, dontFlipX) {
-    if (source.format !== PIXELFORMAT_RGBA8) {
-        Debug.error("ERROR: SH: cubemap must be RGBA8");
-        return null;
-    }
-    if (!source._levels[0] || !source._levels[0][0]) {
-        Debug.error("ERROR: SH: cubemap must be synced to CPU");
-        return null;
-    }
-
-    const cubeSize = source.width;
-
-    if (!source._levels[0][0].length) {
-        // Cubemap is not composed of arrays
-        if (source._levels[0][0] instanceof HTMLImageElement) {
-            // Cubemap is made of imgs - convert to arrays
-            const shader = createShaderFromCode(device,
-                                                shaderChunks.fullscreenQuadVS,
-                                                shaderChunks.fullscreenQuadPS,
-                                                "fsQuadSimple");
-            const constantTexSource = device.scope.resolve("source");
-            for (let face = 0; face < 6; face++) {
-                const img = source._levels[0][face];
-
-                const tex = new Texture(device, {
-                    name: 'prefiltered-cube',
-                    cubemap: false,
-                    type: TEXTURETYPE_DEFAULT,
-                    format: source.format,
-                    width: cubeSize,
-                    height: cubeSize,
-                    mipmaps: false
-                });
-                tex._levels[0] = img;
-                tex.upload();
-
-                const tex2 = new Texture(device, {
-                    name: 'prefiltered-cube',
-                    cubemap: false,
-                    type: TEXTURETYPE_DEFAULT,
-                    format: source.format,
-                    width: cubeSize,
-                    height: cubeSize,
-                    mipmaps: false
-                });
-
-                const targ = new RenderTarget({
-                    colorBuffer: tex2,
-                    depth: false
-                });
-                constantTexSource.setValue(tex);
-                device.setBlendState(BlendState.NOBLEND);
-                drawQuadWithShader(device, targ, shader);
-
-                const gl = device.gl;
-                gl.bindFramebuffer(gl.FRAMEBUFFER, targ.impl._glFrameBuffer);
-
-                const pixels = new Uint8Array(cubeSize * cubeSize * 4);
-                gl.readPixels(0, 0, tex.width, tex.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
-
-                source._levels[0][face] = pixels;
-            }
-        } else {
-            Debug.error("ERROR: SH: cubemap must be composed of arrays or images");
-            return null;
-        }
-    }
-
-    const dirs = [];
-    for (let y = 0; y < cubeSize; y++) {
-        for (let x = 0; x < cubeSize; x++) {
-            const u = (x / (cubeSize - 1)) * 2 - 1;
-            const v = (y / (cubeSize - 1)) * 2 - 1;
-            dirs[y * cubeSize + x] = new Vec3(u, v, 1.0).normalize();
-        }
-    }
-
-    const sh = new Float32Array(9 * 3);
-    const coef1 = 0;
-    const coef2 = 1 * 3;
-    const coef3 = 2 * 3;
-    const coef4 = 3 * 3;
-    const coef5 = 4 * 3;
-    const coef6 = 5 * 3;
-    const coef7 = 6 * 3;
-    const coef8 = 7 * 3;
-    const coef9 = 8 * 3;
-
-    const nx = 0;
-    const px = 1;
-    const ny = 2;
-    const py = 3;
-    const nz = 4;
-    const pz = 5;
-
-    let accum = 0;
-
-    for (let face = 0; face < 6; face++) {
-        for (let y = 0; y < cubeSize; y++) {
-            for (let x = 0; x < cubeSize; x++) {
-
-                const addr = y * cubeSize + x;
-                const weight = texelCoordSolidAngle(x, y, cubeSize);
-
-                // http://home.comcast.net/~tom_forsyth/blog.wiki.html#[[Spherical%20Harmonics%20in%20Actual%20Games%20notes]]
-                const weight1 = weight * 4 / 17;
-                const weight2 = weight * 8 / 17;
-                const weight3 = weight * 15 / 17;
-                const weight4 = weight * 5 / 68;
-                const weight5 = weight * 15 / 68;
-
-                const dir = dirs[addr];
-
-                let dx, dy, dz;
-                if (face === nx) {
-                    dx = dir.z;
-                    dy = -dir.y;
-                    dz = -dir.x;
-                } else if (face === px) {
-                    dx = -dir.z;
-                    dy = -dir.y;
-                    dz = dir.x;
-                } else if (face === ny) {
-                    dx = dir.x;
-                    dy = dir.z;
-                    dz = dir.y;
-                } else if (face === py) {
-                    dx = dir.x;
-                    dy = -dir.z;
-                    dz = -dir.y;
-                } else if (face === nz) {
-                    dx = dir.x;
-                    dy = -dir.y;
-                    dz = dir.z;
-                } else if (face === pz) {
-                    dx = -dir.x;
-                    dy = -dir.y;
-                    dz = -dir.z;
-                }
-
-                if (!dontFlipX) dx = -dx; // flip original cubemap x instead of doing it at runtime
-
-                const a = source._levels[0][face][addr * 4 + 3] / 255.0;
-
-                for (let c = 0; c < 3; c++) {
-                    let value =  source._levels[0][face][addr * 4 + c] / 255.0;
-                    if (source.type === TEXTURETYPE_RGBM) {
-                        value *= a * 8.0;
-                        value *= value;
-                    } else {
-                        value = Math.pow(value, 2.2);
-                    }
-
-                    sh[coef1 + c] += value * weight1;
-                    sh[coef2 + c] += value * weight2 * dx;
-                    sh[coef3 + c] += value * weight2 * dy;
-                    sh[coef4 + c] += value * weight2 * dz;
-
-                    sh[coef5 + c] += value * weight3 * dx * dz;
-                    sh[coef6 + c] += value * weight3 * dz * dy;
-                    sh[coef7 + c] += value * weight3 * dy * dx;
-
-                    sh[coef8 + c] += value * weight4 * (3.0 * dz * dz - 1.0);
-                    sh[coef9 + c] += value * weight5 * (dx * dx - dy * dy);
-
-                    accum += weight;
-                }
-            }
-        }
-    }
-
-    for (let c = 0; c < sh.length; c++) {
-        sh[c] *= 4 * Math.PI / accum;
-    }
-
-    return sh;
-}
-
-export { shFromCubemap };
diff --git a/src/scene/gsplat/gsplat-compressed-material.js b/src/scene/gsplat/gsplat-compressed-material.js
index 93601d4c3..8c96a71b9 100644
--- a/src/scene/gsplat/gsplat-compressed-material.js
+++ b/src/scene/gsplat/gsplat-compressed-material.js
@@ -1,7 +1,7 @@
 import { CULLFACE_NONE, SEMANTIC_ATTR13, SEMANTIC_POSITION } from "../../platform/graphics/constants.js";
 import { ShaderProcessorOptions } from "../../platform/graphics/shader-processor-options.js";
 import { BLEND_NONE, BLEND_NORMAL, DITHER_NONE, TONEMAP_LINEAR } from "../constants.js";
-import { Material } from "../materials/material.js";
+import { ShaderMaterial } from "../materials/shader-material.js";
 import { getProgramLibrary } from "../shader-lib/get-program-library.js";
 
 import { hashCode } from "../../core/hash.js";
@@ -359,7 +359,7 @@ const createGSplatCompressedMaterial = (options = {}) => {
     const ditherEnum = options.dither ?? DITHER_NONE;
     const dither = ditherEnum !== DITHER_NONE;
 
-    const material = new Material();
+    const material = new ShaderMaterial();
     material.name = 'compressedSplatMaterial';
     material.cull = CULLFACE_NONE;
     material.blendType = dither ? BLEND_NONE : BLEND_NORMAL;
diff --git a/src/scene/gsplat/gsplat-material.js b/src/scene/gsplat/gsplat-material.js
index 57d4dd8f1..ba036fd21 100644
--- a/src/scene/gsplat/gsplat-material.js
+++ b/src/scene/gsplat/gsplat-material.js
@@ -1,7 +1,7 @@
 import { CULLFACE_NONE } from "../../platform/graphics/constants.js";
 import { ShaderProcessorOptions } from "../../platform/graphics/shader-processor-options.js";
 import { BLEND_NONE, BLEND_NORMAL, DITHER_NONE } from "../constants.js";
-import { Material } from "../materials/material.js";
+import { ShaderMaterial } from "../materials/shader-material.js";
 import { getProgramLibrary } from "../shader-lib/get-program-library.js";
 import { gsplat } from "./shader-generator-gsplat.js";
 
@@ -61,7 +61,7 @@ const createGSplatMaterial = (options = {}) => {
     const ditherEnum = options.dither ?? DITHER_NONE;
     const dither = ditherEnum !== DITHER_NONE;
 
-    const material = new Material();
+    const material = new ShaderMaterial();
     material.name = 'splatMaterial';
     material.cull = CULLFACE_NONE;
     material.blendType = dither ? BLEND_NONE : BLEND_NORMAL;
diff --git a/src/scene/immediate/immediate.js b/src/scene/immediate/immediate.js
index bab615cb7..0a71d7204 100644
--- a/src/scene/immediate/immediate.js
+++ b/src/scene/immediate/immediate.js
@@ -1,11 +1,10 @@
-import { PRIMITIVE_TRISTRIP } from '../../platform/graphics/constants.js';
+import { PRIMITIVE_TRISTRIP, SEMANTIC_POSITION } from '../../platform/graphics/constants.js';
 
 import { BLEND_NORMAL } from '../constants.js';
 import { GraphNode } from '../graph-node.js';
 import { Mesh } from '../mesh.js';
 import { MeshInstance } from '../mesh-instance.js';
 import { BasicMaterial } from '../materials/basic-material.js';
-import { createShaderFromCode } from '../shader-lib/utils.js';
 import { shaderChunks } from '../shader-lib/chunks/chunks.js';
 import { ImmediateBatches } from './immediate-batches.js';
 
@@ -16,6 +15,8 @@ const tempPoints = [];
 const vec = new Vec3();
 
 class Immediate {
+    shaderDescs = new Map();
+
     constructor(device) {
         this.device = device;
         this.quadMesh = null;
@@ -85,8 +86,8 @@ class Immediate {
         return batches.getBatch(material, layer);
     }
 
-    getShader(id, fragment) {
-        if (!this[id]) {
+    getShaderDesc(id, fragment) {
+        if (!this.shaderDescs.has(id)) {
             // shared vertex shader for textured quad rendering
             const vertex = /* glsl */ `
                 attribute vec2 vertex_position;
@@ -98,15 +99,20 @@ class Immediate {
                 }
             `;
 
-            this[id] = createShaderFromCode(this.device, vertex, fragment, `DebugShader:${id}`);
+            this.shaderDescs.set(id, {
+                uniqueName: `DebugShader:${id}`,
+                vertexCode: vertex,
+                fragmentCode: fragment,
+                attributes: { vertex_position: SEMANTIC_POSITION }
+            });
         }
-        return this[id];
+        return this.shaderDescs.get(id);
     }
 
     // shader used to display texture
-    getTextureShader(encoding) {
+    getTextureShaderDesc(encoding) {
         const decodeFunc = ChunkUtils.decodeFunc(encoding);
-        return this.getShader(`textureShader-${encoding}`, shaderChunks.decodePS + shaderChunks.gamma2_2PS +
+        return this.getShaderDesc(`textureShader-${encoding}`, shaderChunks.decodePS + shaderChunks.gamma2_2PS +
         /* glsl */ `
             varying vec2 uv0;
             uniform sampler2D colorMap;
@@ -118,8 +124,8 @@ class Immediate {
     }
 
     // shader used to display infilterable texture sampled using texelFetch
-    getUnfilterableTextureShader() {
-        return this.getShader('textureShaderUnfilterable', /* glsl */ `
+    getUnfilterableTextureShaderDesc() {
+        return this.getShaderDesc('textureShaderUnfilterable', /* glsl */ `
             varying vec2 uv0;
             uniform highp sampler2D colorMap;
             void main (void) {
@@ -130,8 +136,8 @@ class Immediate {
     }
 
     // shader used to display depth texture
-    getDepthTextureShader() {
-        return this.getShader('depthTextureShader', /* glsl */ `
+    getDepthTextureShaderDesc() {
+        return this.getShaderDesc('depthTextureShader', /* glsl */ `
             ${shaderChunks.screenDepthPS}
             varying vec2 uv0;
             void main() {
diff --git a/src/scene/materials/material.js b/src/scene/materials/material.js
index ba83c6141..9bf0b68d5 100644
--- a/src/scene/materials/material.js
+++ b/src/scene/materials/material.js
@@ -9,13 +9,11 @@ import {
 } from '../../platform/graphics/constants.js';
 import { BlendState } from '../../platform/graphics/blend-state.js';
 import { DepthState } from '../../platform/graphics/depth-state.js';
-import { ShaderProcessorOptions } from '../../platform/graphics/shader-processor-options.js';
 import {
     BLEND_ADDITIVE, BLEND_NORMAL, BLEND_NONE, BLEND_PREMULTIPLIED,
     BLEND_MULTIPLICATIVE, BLEND_ADDITIVEALPHA, BLEND_MULTIPLICATIVE2X, BLEND_SCREEN,
     BLEND_MIN, BLEND_MAX, BLEND_SUBTRACTIVE
 } from '../constants.js';
-import { processShader } from '../shader-lib/utils.js';
 import { getDefaultMaterial } from './default-material.js';
 
 /**
@@ -48,16 +46,6 @@ let id = 0;
  * @category Graphics
  */
 class Material {
-    /**
-     * A shader used to render the material. Note that this is used only by materials where the
-     * user specifies the shader. Most material types generate multiple shader variants, and do not
-     * set this.
-     *
-     * @type {Shader}
-     * @private
-     */
-    _shader = null;
-
     /**
      * The mesh instances referencing this material
      *
@@ -153,6 +141,13 @@ class Material {
      */
     stencilBack = null;
 
+    /** @protected */
+    constructor() {
+        if (new.target === Material) {
+            Debug.error('Material class cannot be instantiated, use ShaderMaterial instead');
+        }
+    }
+
     /**
      * Sets the offset for the output depth buffer value. Useful for decals to prevent z-fighting.
      * Typically a small negative value (-0.1) is used to render the mesh slightly closer to the
@@ -283,24 +278,6 @@ class Material {
         return this._blendState.alphaWrite;
     }
 
-    /**
-     * Sets the shader used by this material to render mesh instances. Defaults to `null`.
-     *
-     * @type {Shader|null}
-     */
-    set shader(shader) {
-        this._shader = shader;
-    }
-
-    /**
-     * Gets the shader used by this material to render mesh instances.
-     *
-     * @type {Shader|null}
-     */
-    get shader() {
-        return this._shader;
-    }
-
     // returns boolean depending on material being transparent
     get transparent() {
         return this._blendState.blend;
@@ -538,10 +515,7 @@ class Material {
     }
 
     getShaderVariant(device, scene, objDefs, renderParams, pass, sortedLights, viewUniformFormat, viewBindGroupFormat, vertexFormat) {
-
-        // generate shader variant - its the same shader, but with different processing options
-        const processingOptions = new ShaderProcessorOptions(viewUniformFormat, viewBindGroupFormat, vertexFormat);
-        return processShader(this._shader, processingOptions);
+        Debug.assert(false, 'Not implemented');
     }
 
     /**
diff --git a/src/scene/materials/shader-material.js b/src/scene/materials/shader-material.js
new file mode 100644
index 000000000..a293fbd68
--- /dev/null
+++ b/src/scene/materials/shader-material.js
@@ -0,0 +1,114 @@
+import { ShaderProcessorOptions } from '../../platform/graphics/shader-processor-options.js';
+import { getProgramLibrary } from '../shader-lib/get-program-library.js';
+import { shaderGeneratorShader } from '../shader-lib/programs/shader-generator-shader.js';
+import { Material } from './material.js';
+
+/**
+ * @typedef {object} ShaderDesc - The description of the shader used by the {@link ShaderMaterial}.
+ * @property {string} uniqueName - Unique name for the shader. If a shader with this name already
+ * exists, it will be returned instead of a new shader instance.
+ * @property {string} [vertexCode] - The vertex shader code.
+ * @property {string} [fragmentCode] - The fragment shader code.
+ * @property {Object<string, string>} [attributes] - Object detailing the mapping of vertex shader
+ * attribute names to semantics SEMANTIC_*. This enables the engine to match vertex buffer data as
+ * inputs to the shader. Defaults to undefined, which generates the default attributes.
+ * @param {string | string[]} [fragmentOutputTypes] - Fragment shader output types, which default to
+ * vec4. Passing a string will set the output type for all color attachments. Passing an array will
+ * set the output type for each color attachment. @see ShaderUtils.createDefinition
+ */
+
+/**
+ * A ShaderMaterial is a type of material that utilizes a specified shader for rendering purposes.
+ *
+ * A simple example which creates a material with custom vertex and fragment shaders:
+ *
+ * ```javascript
+ * const material = new pc.ShaderMaterial({
+ *     uniqueName: 'MyShader',
+ *     attributes: { aPosition: pc.SEMANTIC_POSITION },
+ *     vertexCode: `
+ *         attribute vec3 aPosition;
+ *         uniform mat4 matrix_viewProjection;
+ *         void main(void)
+ *         {
+ *             gl_Position = matrix_viewProjection * pos;
+ *         }`,
+ *     fragmentCode: `
+ *         void main(void) {
+ *             gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
+ *         }`
+ * });
+ * ```
+ *
+ * @category Graphics
+ */
+class ShaderMaterial extends Material {
+    /**
+     * @type {ShaderDesc|undefined}
+     * @private
+     */
+    _shaderDesc;
+
+    /**
+     * Create a new ShaderMaterial instance.
+     *
+     * @param {ShaderDesc} [shaderDesc] - The description of the shader to be used by the material.
+     */
+    constructor(shaderDesc) {
+        super();
+
+        this.shaderDesc = shaderDesc;
+    }
+
+    /**
+     * Sets the shader description.
+     *
+     * @type {ShaderDesc|undefined}
+     */
+    set shaderDesc(value) {
+
+        // shallow clone the object
+        this._shaderDesc = value ? { ...value } : undefined;
+        this.clearVariants();
+    }
+
+    /**
+     * Gets the shader description.
+     *
+     * @type {ShaderDesc|undefined}
+     */
+    get shaderDesc() {
+        return this._shaderDesc;
+    }
+
+    /**
+     * Copy a `ShaderMaterial`.
+     *
+     * @param {ShaderMaterial} source - The material to copy from.
+     * @returns {ShaderMaterial} The destination material.
+     */
+    copy(source) {
+        super.copy(source);
+        this.shaderDesc = source.shaderDesc;
+        return this;
+    }
+
+    getShaderVariant(device, scene, objDefs, renderParams, pass, sortedLights, viewUniformFormat, viewBindGroupFormat, vertexFormat) {
+
+        const options = {
+            pass: pass,
+            gamma: renderParams.shaderOutputGamma,
+            toneMapping: renderParams.toneMapping,
+            shaderDesc: this.shaderDesc
+        };
+
+        const processingOptions = new ShaderProcessorOptions(viewUniformFormat, viewBindGroupFormat, vertexFormat);
+
+        const library = getProgramLibrary(device);
+        library.register('shader-material', shaderGeneratorShader);
+
+        return library.getProgram('shader-material', options, processingOptions, this.userId);
+    }
+}
+
+export { ShaderMaterial };
diff --git a/src/scene/materials/standard-material.js b/src/scene/materials/standard-material.js
index 6e74d2e23..f91cfe4b2 100644
--- a/src/scene/materials/standard-material.js
+++ b/src/scene/materials/standard-material.js
@@ -596,15 +596,6 @@ class StandardMaterial extends Material {
         this._uniformCache = { };
     }
 
-    set shader(shader) {
-        Debug.warn('StandardMaterial#shader property is not implemented, and should not be used.');
-    }
-
-    get shader() {
-        Debug.warn('StandardMaterial#shader property is not implemented, and should not be used.');
-        return null;
-    }
-
     /**
      * Object containing custom shader chunks that will replace default ones.
      *
diff --git a/src/scene/shader-lib/program-library.js b/src/scene/shader-lib/program-library.js
index 627e77448..8606a1215 100644
--- a/src/scene/shader-lib/program-library.js
+++ b/src/scene/shader-lib/program-library.js
@@ -180,7 +180,9 @@ class ProgramLibrary {
                 fincludes: generatedShaderDef.fincludes,
                 fshader: generatedShaderDef.fshader,
                 processingOptions: processingOptions,
-                shaderLanguage: generatedShaderDef.shaderLanguage
+                shaderLanguage: generatedShaderDef.shaderLanguage,
+                meshUniformBufferFormat: generatedShaderDef.meshUniformBufferFormat,
+                meshBindGroupFormat: generatedShaderDef.meshBindGroupFormat
             };
 
             // add new shader to the processed cache
diff --git a/src/scene/shader-lib/programs/shader-generator-shader.js b/src/scene/shader-lib/programs/shader-generator-shader.js
new file mode 100644
index 000000000..49a59d479
--- /dev/null
+++ b/src/scene/shader-lib/programs/shader-generator-shader.js
@@ -0,0 +1,100 @@
+import { hashCode } from '../../../core/hash.js';
+import { SHADERLANGUAGE_WGSL } from '../../../platform/graphics/constants.js';
+import { ShaderUtils } from '../../../platform/graphics/shader-utils.js';
+import { ShaderPass } from '../../shader-pass.js';
+import { ShaderGenerator } from './shader-generator.js';
+
+const vShader = `
+    #include "shaderPassDefines"
+    #include "userCode"
+`;
+
+const fShader = `
+    #include "shaderPassDefines"
+    #include "userCode"
+`;
+
+class ShaderGeneratorShader extends ShaderGenerator {
+    generateKey(options) {
+        const desc = options.shaderDesc;
+        const vsHash = desc.vertexCode ? hashCode(desc.vertexCode) : 0;
+        const fsHash = desc.fragmentCode ? hashCode(desc.fragmentCode) : 0;
+
+        let key = `${desc.uniqueName}_${vsHash}_${fsHash}`;
+        key += '_' + options.pass;
+        key += '_' + options.gamma;
+        key += '_' + options.toneMapping;
+
+        return key;
+    }
+
+    createVertexDefinition(definitionOptions, options, shaderPassInfo) {
+
+        const desc = options.shaderDesc;
+
+        if (definitionOptions.shaderLanguage === SHADERLANGUAGE_WGSL) {
+
+            // TODO: WGSL doesn't have preprocessor connected at the moment, so we just directly use
+            // the provided code. This will be fixed in the future.
+            definitionOptions.vertexCode = desc.vertexCode;
+
+        } else {
+            const includes = new Map();
+            const defines = new Map();
+
+            includes.set('shaderPassDefines', shaderPassInfo.shaderDefines);
+            includes.set('userCode', desc.vertexCode);
+
+            definitionOptions.vertexCode = vShader;
+            definitionOptions.vertexIncludes = includes;
+            definitionOptions.vertexDefines = defines;
+        }
+    }
+
+    createFragmentDefinition(definitionOptions, options, shaderPassInfo) {
+
+        const desc = options.shaderDesc;
+
+        if (definitionOptions.shaderLanguage === SHADERLANGUAGE_WGSL) {
+
+            // TODO: WGSL doesn't have preprocessor connected at the moment, so we just directly use
+            // the provided code. This will be fixed in the future.
+            definitionOptions.fragmentCode = desc.fragmentCode;
+
+        } else {
+            const includes = new Map();
+            const defines = new Map();
+
+            includes.set('shaderPassDefines', shaderPassInfo.shaderDefines);
+            includes.set('userCode', desc.fragmentCode);
+
+            definitionOptions.fragmentCode = fShader;
+            definitionOptions.fragmentIncludes = includes;
+            definitionOptions.fragmentDefines = defines;
+        }
+    }
+
+    createShaderDefinition(device, options) {
+
+        const shaderPassInfo = ShaderPass.get(device).getByIndex(options.pass);
+        const desc = options.shaderDesc;
+
+        const definitionOptions = {
+            name: `ShaderMaterial-${desc.uniqueName}`,
+            attributes: desc.attributes,
+            shaderLanguage: desc.shaderLanguage,
+            fragmentOutputTypes: desc.fragmentOutputTypes,
+            meshUniformBufferFormat: desc.meshUniformBufferFormat,
+            meshBindGroupFormat: desc.meshBindGroupFormat
+        };
+
+        this.createVertexDefinition(definitionOptions, options, shaderPassInfo);
+        this.createFragmentDefinition(definitionOptions, options, shaderPassInfo);
+
+        return ShaderUtils.createDefinition(device, definitionOptions);
+    }
+}
+
+const shaderGeneratorShader = new ShaderGeneratorShader();
+
+export { shaderGeneratorShader };
diff --git a/src/scene/skybox/sky-mesh.js b/src/scene/skybox/sky-mesh.js
index ca7bda4de..02a58cc01 100644
--- a/src/scene/skybox/sky-mesh.js
+++ b/src/scene/skybox/sky-mesh.js
@@ -1,7 +1,7 @@
 import { CULLFACE_FRONT } from '../../platform/graphics/constants.js';
 import { ShaderProcessorOptions } from '../../platform/graphics/shader-processor-options.js';
 import { LAYERID_SKYBOX } from '../constants.js';
-import { Material } from '../materials/material.js';
+import { ShaderMaterial } from '../materials/shader-material.js';
 import { MeshInstance } from '../mesh-instance.js';
 import { getProgramLibrary } from '../shader-lib/get-program-library.js';
 import { skybox } from '../shader-lib/programs/skybox.js';
@@ -34,7 +34,7 @@ class SkyMesh {
      */
     constructor(device, scene, node, texture, type) {
 
-        const material = new Material();
+        const material = new ShaderMaterial();
         material.name = 'SkyMaterial';
 
         material.getShaderVariant = function (dev, sc, defs, renderParams, pass, sortedLights, viewUniformFormat, viewBindGroupFormat) {


Test patch:
diff --git a/test/scene/materials/material.test.mjs b/test/scene/materials/shader-material.test.mjs
similarity index 83%
rename from test/scene/materials/material.test.mjs
rename to test/scene/materials/shader-material.test.mjs
index e4c25ec35..eaa882d5a 100644
--- a/test/scene/materials/material.test.mjs
+++ b/test/scene/materials/shader-material.test.mjs
@@ -1,13 +1,13 @@
 import { CULLFACE_BACK, FUNC_LESSEQUAL } from '../../../src/platform/graphics/constants.js';
 import { BLEND_NONE } from '../../../src/scene/constants.js';
-import { Material } from '../../../src/scene/materials/material.js';
+import { ShaderMaterial } from '../../../src/scene/materials/shader-material.js';
 
 import { expect } from 'chai';
 
 describe('Material', function () {
 
     function checkDefaultMaterial(material) {
-        expect(material).to.be.an.instanceof(Material);
+        expect(material).to.be.an.instanceof(ShaderMaterial);
         expect(material.alphaTest).to.equal(0);
         expect(material.alphaToCoverage).to.equal(false);
         expect(material.alphaWrite).to.equal(true);
@@ -21,7 +21,6 @@ describe('Material', function () {
         expect(material.greenWrite).to.equal(true);
         expect(material.name).to.equal('Untitled');
         expect(material.redWrite).to.equal(true);
-        expect(material.shader).to.be.null;
         expect(material.slopeDepthBias).to.equal(0);
         expect(material.stencilBack).to.not.exist;
         expect(material.stencilFront).to.not.exist;
@@ -30,7 +29,7 @@ describe('Material', function () {
     describe('#constructor()', function () {
 
         it('should create a new instance', function () {
-            const material = new Material();
+            const material = new ShaderMaterial();
             checkDefaultMaterial(material);
         });
 
@@ -39,7 +38,7 @@ describe('Material', function () {
     describe('#clone()', function () {
 
         it('should clone a material', function () {
-            const material = new Material();
+            const material = new ShaderMaterial();
             const clone = material.clone();
             checkDefaultMaterial(clone);
         });
@@ -49,8 +48,8 @@ describe('Material', function () {
     describe('#copy()', function () {
 
         it('should copy a material', function () {
-            const src = new Material();
-            const dst = new Material();
+            const src = new ShaderMaterial();
+            const dst = new ShaderMaterial();
             dst.copy(src);
             checkDefaultMaterial(dst);
         });