Repository: playcanvas/engine
Pull request #6833 (2024-07-19 09:26:08)

Renamed internal ‘descr’ symbols to ‘desc’ for consistency
simple rename, no functional change


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 5a14d993f..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';
 
@@ -167,6 +162,14 @@ export { RenderPassColorGrab } from './scene/graphics/render-pass-color-grab.js'
 export { RenderPassShaderQuad } from './scene/graphics/render-pass-shader-quad.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-bind-group-format.js b/src/platform/graphics/webgpu/webgpu-bind-group-format.js
index a9f65e18b..6d7e189fe 100644
--- a/src/platform/graphics/webgpu/webgpu-bind-group-format.js
+++ b/src/platform/graphics/webgpu/webgpu-bind-group-format.js
@@ -42,7 +42,7 @@ class WebgpuBindGroupFormat {
         /** @type {WebgpuGraphicsDevice} */
         const device = bindGroupFormat.device;
 
-        const { key, descr } = this.createDescriptor(bindGroupFormat);
+        const { key, desc } = this.createDescriptor(bindGroupFormat);
 
         /**
          * Unique key, used for caching
@@ -51,16 +51,16 @@ class WebgpuBindGroupFormat {
          */
         this.key = stringIds.get(key);
 
-        // keep descr in debug mode
+        // keep desc in debug mode
         Debug.call(() => {
-            this.descr = descr;
+            this.desc = desc;
         });
 
         /**
          * @type {GPUBindGroupLayout}
          * @private
          */
-        this.bindGroupLayout = device.wgpu.createBindGroupLayout(descr);
+        this.bindGroupLayout = device.wgpu.createBindGroupLayout(desc);
         DebugHelper.setLabel(this.bindGroupLayout, bindGroupFormat.name);
     }
 
@@ -211,13 +211,13 @@ class WebgpuBindGroupFormat {
         });
 
         /** @type {GPUBindGroupLayoutDescriptor} */
-        const descr = {
+        const desc = {
             entries: entries
         };
 
         return {
             key,
-            descr
+            desc
         };
     }
 }
diff --git a/src/platform/graphics/webgpu/webgpu-bind-group.js b/src/platform/graphics/webgpu/webgpu-bind-group.js
index bf0a960b6..bc663f909 100644
--- a/src/platform/graphics/webgpu/webgpu-bind-group.js
+++ b/src/platform/graphics/webgpu/webgpu-bind-group.js
@@ -23,15 +23,15 @@ class WebgpuBindGroup {
         const device = bindGroup.device;
 
         /** @type {GPUBindGroupDescriptor} */
-        const descr = this.createDescriptor(device, bindGroup);
+        const desc = this.createDescriptor(device, bindGroup);
 
         WebgpuDebug.validate(device);
 
-        this.bindGroup = device.wgpu.createBindGroup(descr);
+        this.bindGroup = device.wgpu.createBindGroup(desc);
 
         WebgpuDebug.end(device, {
             debugFormat: this.debugFormat,
-            descr: descr,
+            desc: desc,
             format: bindGroup.format,
             bindGroup: bindGroup
         });
@@ -158,14 +158,14 @@ class WebgpuBindGroup {
             });
         });
 
-        const descr = {
+        const desc = {
             layout: bindGroup.format.impl.bindGroupLayout,
             entries: entries
         };
 
-        DebugHelper.setLabel(descr, bindGroup.name);
+        DebugHelper.setLabel(desc, bindGroup.name);
 
-        return descr;
+        return desc;
     }
 }
 
diff --git a/src/platform/graphics/webgpu/webgpu-compute-pipeline.js b/src/platform/graphics/webgpu/webgpu-compute-pipeline.js
index a6d63f30e..fb507fa72 100644
--- a/src/platform/graphics/webgpu/webgpu-compute-pipeline.js
+++ b/src/platform/graphics/webgpu/webgpu-compute-pipeline.js
@@ -29,7 +29,7 @@ class WebgpuComputePipeline extends WebgpuPipeline {
         const webgpuShader = shader.impl;
 
         /** @type {GPUComputePipelineDescriptor} */
-        const descr = {
+        const desc = {
             compute: {
                 module: webgpuShader.getComputeShaderModule(),
                 entryPoint: webgpuShader.computeEntryPoint
@@ -42,16 +42,16 @@ class WebgpuComputePipeline extends WebgpuPipeline {
         WebgpuDebug.validate(this.device);
 
         _pipelineId++;
-        DebugHelper.setLabel(descr, `ComputePipelineDescr-${_pipelineId}`);
+        DebugHelper.setLabel(desc, `ComputePipelineDescr-${_pipelineId}`);
 
-        const pipeline = wgpu.createComputePipeline(descr);
+        const pipeline = wgpu.createComputePipeline(desc);
 
         DebugHelper.setLabel(pipeline, `ComputePipeline-${_pipelineId}`);
-        Debug.trace(TRACEID_COMPUTEPIPELINE_ALLOC, `Alloc: Id ${_pipelineId}`, descr);
+        Debug.trace(TRACEID_COMPUTEPIPELINE_ALLOC, `Alloc: Id ${_pipelineId}`, desc);
 
         WebgpuDebug.end(this.device, {
             computePipeline: this,
-            descr,
+            desc: desc,
             shader
         });
 
diff --git a/src/platform/graphics/webgpu/webgpu-mipmap-renderer.js b/src/platform/graphics/webgpu/webgpu-mipmap-renderer.js
index 187052719..a5087f24d 100644
--- a/src/platform/graphics/webgpu/webgpu-mipmap-renderer.js
+++ b/src/platform/graphics/webgpu/webgpu-mipmap-renderer.js
@@ -73,7 +73,7 @@ class WebgpuMipmapRenderer {
     generate(webgpuTexture) {
 
         // ignore texture with no mipmaps
-        const textureDescr = webgpuTexture.descr;
+        const textureDescr = webgpuTexture.desc;
         if (textureDescr.mipLevelCount <= 1) {
             return;
         }
diff --git a/src/platform/graphics/webgpu/webgpu-pipeline.js b/src/platform/graphics/webgpu/webgpu-pipeline.js
index 264f7ec19..038fbafc8 100644
--- a/src/platform/graphics/webgpu/webgpu-pipeline.js
+++ b/src/platform/graphics/webgpu/webgpu-pipeline.js
@@ -30,18 +30,18 @@ class WebgpuPipeline {
             bindGroupLayouts.push(format.bindGroupLayout);
         });
 
-        const descr = {
+        const desc = {
             bindGroupLayouts: bindGroupLayouts
         };
 
         _layoutId++;
-        DebugHelper.setLabel(descr, `PipelineLayoutDescr-${_layoutId}`);
+        DebugHelper.setLabel(desc, `PipelineLayoutDescr-${_layoutId}`);
 
         /** @type {GPUPipelineLayout} */
-        const pipelineLayout = this.device.wgpu.createPipelineLayout(descr);
+        const pipelineLayout = this.device.wgpu.createPipelineLayout(desc);
         DebugHelper.setLabel(pipelineLayout, `PipelineLayout-${_layoutId}`);
         Debug.trace(TRACEID_PIPELINELAYOUT_ALLOC, `Alloc: Id ${_layoutId}`, {
-            descr,
+            desc: desc,
             bindGroupFormats
         });
 
diff --git a/src/platform/graphics/webgpu/webgpu-render-pipeline.js b/src/platform/graphics/webgpu/webgpu-render-pipeline.js
index fddceb900..d2dfca00a 100644
--- a/src/platform/graphics/webgpu/webgpu-render-pipeline.js
+++ b/src/platform/graphics/webgpu/webgpu-render-pipeline.js
@@ -304,7 +304,7 @@ class WebgpuRenderPipeline extends WebgpuPipeline {
         const webgpuShader = shader.impl;
 
         /** @type {GPURenderPipelineDescriptor} */
-        const descr = {
+        const desc = {
             vertex: {
                 module: webgpuShader.getVertexShaderModule(),
                 entryPoint: webgpuShader.vertexEntryPoint,
@@ -327,7 +327,7 @@ class WebgpuRenderPipeline extends WebgpuPipeline {
             layout: pipelineLayout
         };
 
-        descr.fragment = {
+        desc.fragment = {
             module: webgpuShader.getFragmentShaderModule(),
             entryPoint: webgpuShader.fragmentEntryPoint,
             targets: []
@@ -347,7 +347,7 @@ class WebgpuRenderPipeline extends WebgpuPipeline {
             const blend = this.getBlend(blendState);
 
             colorAttachments.forEach((attachment) => {
-                descr.fragment.targets.push({
+                desc.fragment.targets.push({
                     format: attachment.format,
                     writeMask: writeMask,
                     blend: blend
@@ -358,16 +358,16 @@ class WebgpuRenderPipeline extends WebgpuPipeline {
         WebgpuDebug.validate(this.device);
 
         _pipelineId++;
-        DebugHelper.setLabel(descr, `RenderPipelineDescr-${_pipelineId}`);
+        DebugHelper.setLabel(desc, `RenderPipelineDescr-${_pipelineId}`);
 
-        const pipeline = wgpu.createRenderPipeline(descr);
+        const pipeline = wgpu.createRenderPipeline(desc);
 
         DebugHelper.setLabel(pipeline, `RenderPipeline-${_pipelineId}`);
-        Debug.trace(TRACEID_RENDERPIPELINE_ALLOC, `Alloc: Id ${_pipelineId}, stack: ${DebugGraphics.toString()}`, descr);
+        Debug.trace(TRACEID_RENDERPIPELINE_ALLOC, `Alloc: Id ${_pipelineId}, stack: ${DebugGraphics.toString()}`, desc);
 
         WebgpuDebug.end(this.device, {
             renderPipeline: this,
-            descr,
+            desc: desc,
             shader
         });
 
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/platform/graphics/webgpu/webgpu-texture.js b/src/platform/graphics/webgpu/webgpu-texture.js
index 6ade0c345..3aad44bb7 100644
--- a/src/platform/graphics/webgpu/webgpu-texture.js
+++ b/src/platform/graphics/webgpu/webgpu-texture.js
@@ -70,7 +70,7 @@ class WebgpuTexture {
      * @type {GPUTextureDescriptor}
      * @private
      */
-    descr;
+    desc;
 
     /**
      * @type {GPUTextureFormat}
@@ -96,7 +96,7 @@ class WebgpuTexture {
 
         Debug.assert(texture.width > 0 && texture.height > 0, `Invalid texture dimensions ${texture.width}x${texture.height} for texture ${texture.name}`, texture);
 
-        this.descr = {
+        this.desc = {
             size: {
                 width: texture.width,
                 height: texture.height,
@@ -117,11 +117,11 @@ class WebgpuTexture {
 
         WebgpuDebug.validate(device);
 
-        this.gpuTexture = wgpu.createTexture(this.descr);
+        this.gpuTexture = wgpu.createTexture(this.desc);
         DebugHelper.setLabel(this.gpuTexture, `${texture.name}${texture.cubemap ? '[cubemap]' : ''}${texture.volume ? '[3d]' : ''}`);
 
         WebgpuDebug.end(device, {
-            descr: this.descr,
+            desc: this.desc,
             texture
         });
 
@@ -163,7 +163,7 @@ class WebgpuTexture {
     createView(viewDescr) {
 
         const options = viewDescr ?? {};
-        const textureDescr = this.descr;
+        const textureDescr = this.desc;
         const texture = this.texture;
 
         // '1d', '2d', '2d-array', 'cube', 'cube-array', '3d'
@@ -175,7 +175,7 @@ class WebgpuTexture {
         };
 
         /** @type {GPUTextureViewDescriptor} */
-        const descr = {
+        const desc = {
             format: options.format ?? textureDescr.format,
             dimension: options.dimension ?? defaultViewDimension(),
             aspect: options.aspect ?? 'all',
@@ -185,7 +185,7 @@ class WebgpuTexture {
             arrayLayerCount: options.arrayLayerCount ?? textureDescr.depthOrArrayLayers
         };
 
-        const view = this.gpuTexture.createView(descr);
+        const view = this.gpuTexture.createView(desc);
         DebugHelper.setLabel(view, `${viewDescr ? `CustomView${JSON.stringify(viewDescr)}` : 'DefaultView'}:${this.texture.name}`);
 
         return view;
@@ -208,7 +208,7 @@ class WebgpuTexture {
             let label;
 
             /** @type GPUSamplerDescriptor */
-            const descr = {
+            const desc = {
                 addressModeU: gpuAddressModes[texture.addressU],
                 addressModeV: gpuAddressModes[texture.addressV],
                 addressModeW: gpuAddressModes[texture.addressW]
@@ -222,16 +222,16 @@ class WebgpuTexture {
             if (sampleType === SAMPLETYPE_DEPTH || sampleType === SAMPLETYPE_INT || sampleType === SAMPLETYPE_UINT) {
 
                 // depth compare sampling
-                descr.compare = 'less';
-                descr.magFilter = 'linear';
-                descr.minFilter = 'linear';
+                desc.compare = 'less';
+                desc.magFilter = 'linear';
+                desc.minFilter = 'linear';
                 label = 'Compare';
 
             } else if (sampleType === SAMPLETYPE_UNFILTERABLE_FLOAT) {
 
-                descr.magFilter = 'nearest';
-                descr.minFilter = 'nearest';
-                descr.mipmapFilter = 'nearest';
+                desc.magFilter = 'nearest';
+                desc.minFilter = 'nearest';
+                desc.mipmapFilter = 'nearest';
                 label = 'Unfilterable';
 
             } else {
@@ -240,30 +240,30 @@ class WebgpuTexture {
                 const forceNearest = !device.textureFloatFilterable && (texture.format === PIXELFORMAT_RGBA32F || texture.format === PIXELFORMAT_RGBA16F);
 
                 if (forceNearest || this.texture.format === PIXELFORMAT_DEPTHSTENCIL || isIntegerPixelFormat(this.texture.format)) {
-                    descr.magFilter = 'nearest';
-                    descr.minFilter = 'nearest';
-                    descr.mipmapFilter = 'nearest';
+                    desc.magFilter = 'nearest';
+                    desc.minFilter = 'nearest';
+                    desc.mipmapFilter = 'nearest';
                     label = 'Nearest';
                 } else {
-                    descr.magFilter = gpuFilterModes[texture.magFilter].level;
-                    descr.minFilter = gpuFilterModes[texture.minFilter].level;
-                    descr.mipmapFilter = gpuFilterModes[texture.minFilter].mip;
+                    desc.magFilter = gpuFilterModes[texture.magFilter].level;
+                    desc.minFilter = gpuFilterModes[texture.minFilter].level;
+                    desc.mipmapFilter = gpuFilterModes[texture.minFilter].mip;
                     Debug.call(() => {
-                        label = `Texture:${texture.magFilter}-${texture.minFilter}-${descr.mipmapFilter}`;
+                        label = `Texture:${texture.magFilter}-${texture.minFilter}-${desc.mipmapFilter}`;
                     });
                 }
             }
 
             // ensure anisotropic filtering is only set when filtering is correctly
             // set up
-            const allLinear = (descr.minFilter === 'linear' &&
-                               descr.magFilter === 'linear' &&
-                               descr.mipmapFilter === 'linear');
-            descr.maxAnisotropy = allLinear ?
+            const allLinear = (desc.minFilter === 'linear' &&
+                               desc.magFilter === 'linear' &&
+                               desc.mipmapFilter === 'linear');
+            desc.maxAnisotropy = allLinear ?
                 math.clamp(Math.round(texture._anisotropy), 1, device.maxTextureAnisotropy) :
                 1;
 
-            sampler = device.wgpu.createSampler(descr);
+            sampler = device.wgpu.createSampler(desc);
             DebugHelper.setLabel(sampler, label);
             this.samplers[sampleType] = sampler;
         }
@@ -407,7 +407,7 @@ class WebgpuTexture {
 
     uploadExternalImage(device, image, mipLevel, index) {
 
-        Debug.assert(mipLevel < this.descr.mipLevelCount, `Accessing mip level ${mipLevel} of texture with ${this.descr.mipLevelCount} mip levels`, this);
+        Debug.assert(mipLevel < this.desc.mipLevelCount, `Accessing mip level ${mipLevel} of texture with ${this.desc.mipLevelCount} mip levels`, this);
 
         const src = {
             source: image,
@@ -423,8 +423,8 @@ class WebgpuTexture {
         };
 
         const copySize = {
-            width: this.descr.size.width,
-            height: this.descr.size.height,
+            width: this.desc.size.width,
+            height: this.desc.size.height,
             depthOrArrayLayers: 1   // single layer
         };
 
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);
         });