Bumpmap Button Examples: CSS, WebGL, and Unity ImplementationsBump mapping is a technique used in computer graphics to simulate surface detail—small bumps and dents—without increasing geometric complexity. Applied to user interface buttons, bump mapping can create tactile, realistic-looking controls that respond convincingly to lighting and interaction. This article explains the concept, shows practical implementations in CSS, WebGL, and Unity, and offers tips and performance considerations.
What is bump mapping?
Bump mapping alters surface normals during shading to give the illusion of depth. Instead of changing actual geometry, a grayscale texture (a bump map or height map) encodes surface height variations. During lighting calculations, the shader perturbs normals based on the bump map values so light interacts as if the surface had bumps and crevices.
- Height map (bump map): grayscale image where lighter = higher, darker = lower.
- Normal map: RGB image encoding normal vectors directly; often derived from a height map and more efficient for per-pixel lighting.
- Parallax mapping / Parallax occlusion mapping: advanced techniques that produce stronger depth illusion and parallax effects at oblique angles.
CSS Implementation
Using pure CSS, we can simulate bump-mapped appearance with layered backgrounds, blend modes, and filters. While CSS cannot modify normals in a shader-like way, combining gradients, textures, and lighting effects yields convincing UI results that are GPU-accelerated and lightweight.
Example goals:
- Button looks slightly raised with subtle surface texture.
- Lighting direction changes on hover to simulate interaction.
HTML:
<button class="bump-btn">Press me</button>
CSS:
:root{ --btn-w: 220px; --btn-h: 60px; --base: #2b6cb0; --light: rgba(255,255,255,0.25); --shadow: rgba(0,0,0,0.25); } /* Base button */ .bump-btn{ width: var(--btn-w); height: var(--btn-h); border: none; border-radius: 12px; color: white; font-weight: 600; font-size: 18px; position: relative; cursor: pointer; background: /* subtle surface texture (semi-transparent noise) */ linear-gradient(transparent, rgba(255,255,255,0.02)), /* base color */ linear-gradient(180deg, var(--base), darken(var(--base), 8%)); box-shadow: 0 6px 18px rgba(0,0,0,0.25), inset 0 2px 6px rgba(255,255,255,0.03); overflow: hidden; transition: transform 180ms ease, box-shadow 180ms ease; display: inline-flex; align-items: center; justify-content: center; } /* Hover/Tap lighting shift */ .bump-btn::before{ content: ""; position: absolute; inset: 0; background: radial-gradient(ellipse at 20% 20%, var(--light), transparent 20%), radial-gradient(ellipse at 80% 80%, rgba(0,0,0,0.06), transparent 30%); mix-blend-mode: overlay; transition: transform 180ms ease, opacity 180ms ease; pointer-events: none; } /* Pressed state — slightly depress */ .bump-btn:active{ transform: translateY(2px) scale(0.995); box-shadow: 0 3px 10px rgba(0,0,0,0.22), inset 0 -3px 8px rgba(0,0,0,0.06); }
Notes:
- CSS can’t compute true per-pixel lighting, but overlaying radial gradients and textured noise approximates bumpmap shading.
- For higher realism, use an SVG filter or WebGL-based canvas.
WebGL Implementation
WebGL allows true per-pixel lighting and normal perturbation using either a normal map or a height map converted in the shader. This section provides a simple WebGL fragment shader example that renders a rectangle as a button with bump mapping using a normal map. The example uses raw WebGL 1.0 GLSL ES; for production consider using Three.js or Pixi.js for convenience.
Vertex shader:
attribute vec2 a_position; attribute vec2 a_texcoord; varying vec2 v_texcoord; void main(){ gl_Position = vec4(a_position, 0.0, 1.0); v_texcoord = a_texcoord; }
Fragment shader (bump mapping with normal map):
precision mediump float; varying vec2 v_texcoord; uniform sampler2D u_color; // base color/texture uniform sampler2D u_normal; // normal map (in tangent space) uniform vec3 u_lightDir; // light direction (normalized) uniform vec3 u_viewDir; // view direction (for specular) uniform float u_kSpec; // specular strength void main(){ vec3 baseColor = texture2D(u_color, v_texcoord).rgb; vec3 n = texture2D(u_normal, v_texcoord).rgb; // Remap from [0,1] to [-1,1] n = normalize(n * 2.0 - 1.0); vec3 L = normalize(u_lightDir); float diff = max(dot(n, L), 0.0); // Blinn-Phong specular vec3 V = normalize(u_viewDir); vec3 H = normalize(L + V); float spec = pow(max(dot(n, H), 0.0), 32.0) * u_kSpec; vec3 color = baseColor * diff + vec3(spec); // Ambient term color += baseColor * 0.08; gl_FragColor = vec4(color, 1.0); }
Integration notes:
- Provide a normal map texture where RGB encodes XY Z of normals. You can generate normal maps from height maps using tools (Photoshop, GIMP, xNormal) or generate procedurally.
- For UI buttons, keep geometry simple (two triangles). Compute proper tangent space if using transformed geometry; for a flat quad aligned with screen, tangent space can be assumed with normal (0,0,1).
- Animate light direction on hover to simulate interaction.
Simple interaction idea:
- On mousemove over the canvas, compute light direction from pointer position relative to button center and update u_lightDir uniform to highlight different parts of the button.
Unity Implementation
Unity supports bump mapping out of the box via normal maps and its Standard Shader. For custom behavior or optimized UI integration, you can use a custom shader (Shader Graph or HLSL). Below are two approaches: using the built-in UI system with a normal-mapped material, and a custom Shader Graph example.
Using Unity UI with a normal map
- Prepare textures:
- Base albedo (RGBA)
- Normal map (import type: Normal Map)
- Create a material using the Built-in “UI/Default” shader doesn’t support normal maps; instead use a shader that does (e.g., “UI/Unlit/Transparent” won’t either). Recommended: use a custom shader from Unity UI Extensions or convert to a mesh with a standard material.
- Alternatively, use a RawImage with a Mesh and Standard shader on a Canvas set to Screen Space – Camera or World Space.
Simple script to change lighting based on pointer position:
using UnityEngine; using UnityEngine.EventSystems; public class UIButtonLight : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerMoveHandler { public Light uiLight; public Renderer rend; void Start() { if (uiLight == null) { uiLight = new GameObject("UI Light").AddComponent<Light>(); uiLight.type = LightType.Directional; uiLight.transform.rotation = Quaternion.Euler(50, -30, 0); } } public void OnPointerEnter(PointerEventData eventData) { uiLight.enabled = true; } public void OnPointerExit(PointerEventData eventData) { uiLight.enabled = false; } public void OnPointerMove(PointerEventData eventData) { Vector2 local; RectTransformUtility.ScreenPointToLocalPointInRectangle( transform as RectTransform, eventData.position, eventData.pressEventCamera, out local); // map local to rotation float x = Mathf.Clamp(local.x / ((RectTransform)transform).rect.width, -0.5f, 0.5f); float y = Mathf.Clamp(local.y / ((RectTransform)transform).rect.height, -0.5f, 0.5f); uiLight.transform.rotation = Quaternion.Euler(50 + y*30, -30 + x*60, 0); } }
Shader Graph approach (URP)
- Create a Shader Graph for a PBR Unlit or Lit Graph.
- Add properties: Albedo (Texture2D), Normal Map (Texture2D), Smoothness, Metallic, Light Direction (Vector).
- Sample Normal Map with “Normal Unpack” node; combine with view/light nodes to compute lighting.
- Expose a vector property for light direction and update it from UI events (via script) to animate highlights.
Tips:
- Use Sprite Renderer or mesh-based RawImage for best control over lighting.
- For mobile, prefer normal maps and keep shader complexity low (disable expensive features like parallax or high-specular calculations).
- Bake subtle ambient occlusion into the base texture for extra depth without runtime cost.
Performance considerations
- CSS: cheapest; use for most UI needs. Avoid heavy filters and large background images.
- WebGL: per-pixel lighting is more expensive but still cheap for a single quad. Batch buttons into a single draw call when possible.
- Unity: hardware-dependent; use simpler PBR settings for mobile and consider atlas textures and shared materials.
When to use which approach
- Use CSS when you want low-cost, widely compatible visuals without true per-pixel lighting.
- Use WebGL (or a web renderer like Three.js) when you need true bump-mapped lighting and interaction on the web.
- Use Unity for native apps and games where you can leverage the engine’s lighting and material system.
Tips for realistic bumpmap buttons
- Use normal maps derived from subtle height maps; exaggerated normals look plastic.
- Keep specular low for cloth-like buttons; increase for glossy UI (metallic switches).
- Animate the light source slightly on hover/press to sell depth.
- Combine small blur and ambient occlusion in the base texture to simulate soft shadowing in crevices.
Bump-mapped buttons are a powerful way to add richness to UI with minimal geometry cost. Choose the technique that best fits your platform and performance budget, and tune normal/specular maps to match the material you’re simulating.
Leave a Reply