Skip to content
Light · Specular highlightsIntermediate

Plastic

Colored diffuse with a white Blinn-Phong highlight that slides across the surface as you orbit.

NormalViewSurface
Open in Playground
  1. 01GoalLight
  2. 02TechniqueSpecular highlights
  3. 03ExecutionPlastic
Test mesh

Parameters

#cc1f1a
64
0.80
0.08
-20
40

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. maxMaxmaxmaxmax
    Normal
    View

    01 Diffuse light

  2. dotDotProductdotdotdot

    02 Normal against the half vector

  3. powPowpowpowpow

    03 Tighten into a highlight

  4. +Add+++

    04 Color plus white highlight

How it works

Red plastic is two things layered: red paint, the dye scattered evenly inside the material, and a clear surface that mirrors a little of the light without coloring it. The shader builds the same two layers. The diffuse layer is Lambert’s, tinted by the plastic’s color; the specular layer is a tight white spot where the surface would bounce the sun into the camera.

Step by step

  1. Diffuse light. The dot product of the normal and the direction toward the sun, clamped at zero, as in the Diffuse technique. It doesn’t change as you orbit.
  2. Normal against the half vector. normalize(toSun + i.view) is the half vector, the direction a mirror’s normal would need to send the sun into the camera. Its dot product with the actual normal is 1 exactly there and falls off around it. Orbit the preview on this step: the bright region follows the camera, because the view direction is now part of the math.
  3. Tighten into a highlight. Raising that alignment to a high power (64 by default) keeps only the values very close to 1, so the broad glow shrinks to a spot. Each doubling of the exponent roughly shrinks the spot’s angular width by √2. Multiplying by the diffuse factor fades the spot out at the terminator and removes it on the side the sun can’t reach, where Blinn-Phong would otherwise still light a sliver at grazing angles.
  4. Color plus white highlight. The plastic’s color multiplies the diffuse light and a small ambient term, and the highlight is added on top as white. Adding it untinted is what reads as plastic: tint it with the base color and the same sphere looks like metal.

Shininess and highlight strength are independent here, which is convenient for art direction but not physical: in reality a tighter highlight is also brighter, because the same reflected energy is packed into a smaller spot. A normalization factor of (shininess + 8) / 8 keeps the energy roughly constant, if you need sliders that behave like a roughness value.

Make it in GLSL

The highlight needs a direction toward the camera per pixel. Compute it in world space as normalize(cameraPosition - worldPosition) in the fragment shader rather than interpolating a normalized vector from the vertices, which bends the highlight on large triangles.

The copy button gives a complete WebGL2 fragment shader. Your vertex shader fills the v_normal and v_view varyings in world space (v_view as cameraPosition - worldPosition, left unnormalized so it interpolates exactly; the generated main() normalizes both per pixel) and declares v_position and v_uv too. Upload the color param decoded to linear, as the header comment says; main() encodes the result to sRGB once.

sunDirection() gives the same vector for every pixel; in your own renderer pass the direction as a uniform instead and save 8 of the 20 operations. Several lights are a loop over the diffuse and highlight lines, adding each light’s contribution before the base color is applied.

Make it in Godot

The exported shader is unshaded with its own sun. To light the same Blinn-Phong from the scene's lights, drop unshaded, set ALBEDO in fragment(), and in a light() function add DIFFUSE_LIGHT += max(dot(NORMAL, LIGHT), 0.0) * ATTENUATION * LIGHT_COLOR / PI; and SPECULAR_LIGHT += pow(max(dot(NORMAL, normalize(LIGHT + VIEW)), 0.0), shininess) * specular * ATTENUATION * LIGHT_COLOR / PI; (Godot multiplies DIFFUSE_LIGHT by ALBEDO but not SPECULAR_LIGHT, so the highlight stays white). Godot's built-in lighting already offers a Blinn-Phong-like look through StandardMaterial3D with a low roughness.

Make a MeshInstance3D with a SphereMesh, give it a new ShaderMaterial, and paste the shader. Raise Shininess under Shader Parameters and the spot shrinks; lower it under about 8 and the highlight spreads into a sheen across the lit side.

In VisualShader, use Input nodes for NORMAL and VIEW (both view space, so transform the sun direction to view space with a TransformVecMult by VIEW_MATRIX, or transform the two inputs to world space). The diffuse factor is a DotProduct into a FloatOp Max with 0. The half vector is a VectorOp Add of the sun and VIEW into a VectorFunc Normalize; a DotProduct with NORMAL, a Max with 0 and a FloatOp Pow by a FloatParameter make the highlight. Multiply it by the strength and the diffuse factor, then Add it to the base color times the diffuse light into Albedo, with the material unshaded.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

plastic.fragGLSL
#version 300 es// Specular highlights → Plastic, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform vec3 p_base; // Plastic color: vec3(0.8, 0.12, 0.1), sRGB; upload it decoded to linearuniform float p_shininess; // Shininess: 64.0 (2.0 to 512.0)uniform float p_specular; // Highlight strength: 0.8 (0.0 to 2.0)uniform float p_ambient; // Ambient light: 0.08 (0.0 to 0.5)uniform float p_azimuth; // Sun azimuth (degrees): -20.0 (-180.0 to 180.0)uniform float p_elevation; // Sun elevation (degrees): 40.0 (-30.0 to 90.0)in vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// The angles turn the same way as the preview's orbit camera: azimuth around the vertical axis from +Z toward +X,// elevation up from the horizon.vec3 sunDirection(float azimuthDegrees, float elevationDegrees) {	float a = radians(azimuthDegrees);	float e = radians(elevationDegrees);	return vec3(cos(e) * sin(a), sin(e), cos(e) * cos(a));}vec4 effect(Inputs i) {	vec3 toSun = sunDirection(p_azimuth, p_elevation);	float diffuse = max(dot(i.normal, toSun), 0.0);	// Blinn-Phong: the halfway direction lines up with the normal exactly where a mirror would bounce the sun into	// the camera, and stays within 90° of it, so the highlight never cuts off the way Phong's reflect() can.	vec3 halfway = normalize(toSun + i.view);	float alignment = max(dot(i.normal, halfway), 0.0);	// Times diffuse: no highlight on the side the sun can't reach, and it fades out at the terminator.	float highlight = pow(alignment, p_shininess) * p_specular * diffuse;	vec3 color = p_base * (diffuse + p_ambient) + vec3(highlight);	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
plastic.gdshaderGodot shader
// Specular highlights → Plastic, Godot 4. From Creative Center, MIT license.shader_type spatial;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform vec3 base : source_color = vec3(0.8, 0.12, 0.1);uniform float shininess : hint_range(2.0, 512.0, 1.0) = 64.0;uniform float specular : hint_range(0.0, 2.0, 0.05) = 0.8;uniform float ambient : hint_range(0.0, 0.5, 0.01) = 0.08;uniform float azimuth : hint_range(-180.0, 180.0, 1.0) = -20.0;uniform float elevation : hint_range(-30.0, 90.0, 1.0) = 40.0;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// The angles turn the same way as the preview's orbit camera: azimuth around the vertical axis from +Z toward +X,// elevation up from the horizon.vec3 sunDirection(float azimuthDegrees, float elevationDegrees) {	float a = radians(azimuthDegrees);	float e = radians(elevationDegrees);	return vec3(cos(e) * sin(a), sin(e), cos(e) * cos(a));}vec4 effect(Inputs i) {	vec3 toSun = sunDirection(azimuth, elevation);	float diffuse = max(dot(i.normal, toSun), 0.0);	// Blinn-Phong: the halfway direction lines up with the normal exactly where a mirror would bounce the sun into	// the camera, and stays within 90° of it, so the highlight never cuts off the way Phong's reflect() can.	vec3 halfway = normalize(toSun + i.view);	float alignment = max(dot(i.normal, halfway), 0.0);	// Times diffuse: no highlight on the side the sun can't reach, and it fades out at the terminator.	float highlight = pow(alignment, shininess) * specular * diffuse;	vec3 color = base * (diffuse + ambient) + vec3(highlight);	return vec4(color, 1.0);}void fragment() {	Inputs i;	// Godot's UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	// VERTEX, NORMAL and VIEW are in view space here; the effect works in world space	i.position = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;	i.normal = normalize((INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);	i.view = normalize((INV_VIEW_MATRIX * vec4(VIEW, 0.0)).xyz);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	ALBEDO = frag_out.rgb;}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading