📜 ⬆️ ⬇️

2D shadows on WebGL in 4 easy steps

In this article, I’ll tell you how to do 2DG shadows on WebGL with your own hands, with just a notebook and any web server. All steps lie on the githaba as branches and switch to git checkout stepN, so welcome even to those who are not configured to code.

KDPV:



You can play with the finished project here .
')
So, recently I wanted to write something with 2D shadows on javascript. With a bunch of light sources and any (read: raster) obstacles for them. Raytracing scared with complex relationships with geometry (especially when it does not fit on the screen), so my choice fell on WebGL shaders.

TK: There is a picture on which obstacles are drawn, there are coordinates of light sources, you need to draw shadows.

We will use pixi.js: a drawing framework, has a renderer displaying a certain scene (an instance of PIXI.Container) on the page. Scene-Container may contain other containers, or already directly sprites / graphics (PIXI.Sprite / PIXI.Graphics, respectively). PIXI.Graphics for drawing, Sprites contain a texture, either drawn too, or loaded from the outside. Also Sprites can be applied filters (shaders).

About Filters: special objects are entered into the filters variable
someGraphicObject.filters = [filter] 

and perform any operations with the texture of this object, including the shader can be superimposed. We will have a special filter consisting of two “subfilters”. Such a "complex" structure is needed because of the method of drawing shadows:

We generate a shadow map (for each light source), a one-dimensional texture depicting obstacles from the point of view of the source, where the X coordinate is the angle, and the alpha channel value is the distance to the obstacle. However, since such a texture is only suitable for one light source, to save space and resources, the texture will still be two-dimensional, Y will mean the number of the source.

Question for understanding: The texture is 256x256, a pixel with coordinates (x = 64, y = 8) is translucent, name the number of the light source, and the polar coordinates of the obstacle relative to the source.

Answer
The number is not greater than eight (the relationship between Y and the number may not be direct), the distance is half of a certain number (again, the dependence may not be direct), the angle is 90 degrees.

Then, from the original image and the shadow map with the second shader, draw the shadows themselves and, at the same time, mix everything there.

Hierarchy with tz. PIXI will be like this:

 Scene (PIXI.Container)
 | - Lightbulbs (PIXI.Graphics pair)
 | - Sprite on which shadows will be drawn + lighted background (PIXI.Sprite)
 | - | - Texture (PIXI.RenderTexture)
 | - | - Filter (PIXI.AbstractFilter)


And separately, outside the scene:

 | - Background (PIXI.Sprite)
 | - | - Texture Background
 | - Obstacle Container (PIXI.Container)
 | - | - Sprite with obstacles (PIXI.Sprite)
 | - | - | - Sprite texture


Note that the background and the container will be passed to the filter that will figure out what to draw and how to draw it.
That's the whole theory, now you can begin to practice:

Step 0: Create a project


Or download it from here , step0 branch.

We need 3 files:


It is also a good idea to start any web server with a project folder right away.

Step 1: Create a Scene


Everything is created in the js / script.js file. The code, I hope, is obvious even to those who are not familiar with PIXI:

js / script.js
 (function () { var WIDTH = 640; var HEIGHT = 480; var renderer = new PIXI.WebGLRenderer(WIDTH, HEIGHT); //  PIXI  document.addEventListener("DOMContentLoaded", function (event) { //   html  document.body.appendChild(renderer.view); //    PIXI.loader .add('background', 'img/maze.png') .once('complete', setup) .load(); }); function setup() { //  ,  . var lights = []; //  lights[0] = new PIXI.Graphics(); lights[0].beginFill(0xFFFF00); lights[0].drawCircle(0, 0, 4); // x, y, radius lights[1] = new PIXI.Graphics(); lights[1].beginFill(0xFFFF00); lights[1].drawCircle(0, 0, 4); lights[1].x = 50; lights[1].y = 50; var background = new PIXI.Graphics(); background.beginFill(0x999999); background.drawRect(0, 0, WIDTH, HEIGHT); // x, y, width, height var shadowCastImage = PIXI.Sprite.fromImage('img/maze.png'); //      () var shadowCasters = new PIXI.Container(); //   ,    shadowCasters.addChild(shadowCastImage); //    . var stage = new PIXI.Container(); stage.addChild(background); stage.addChild(shadowCasters); stage.addChild(lights[0]); stage.addChild(lights[1]); (function animate() { // lights[0]    . var pointer = renderer.plugins.interaction.mouse.global; lights[0].x = pointer.x; lights[0].y = pointer.y; //  renderer.render(stage); requestAnimationFrame(animate); })(); } })(); 


As a result, you will get something intelligible, a picture, two light bulbs, and one will already be tied to the mouse:



Step 2: Add Shader


I'll start with a simple shader, he will paint the obstacles in red, and everything else in gray.

File glsl / smap-shadow-texture.frag:
 precision mediump float; //    varying vec2 vTextureCoord; //    ( 0.0  1.0)   uniform sampler2D uSampler; // ,    . void main() { vec4 color = texture2D(uSampler, vTextureCoord); //    if (color.a == 0.) { //  -  gl_FragColor = vec4(.5, .5, .5, 1.); //   ,  } else { //    gl_FragColor = vec4(1., 0., 0., 1.); //   ,  } } 


We load it in loader'e:

  PIXI.loader ... .add('glslShadowTexture', 'glsl/smap-shadow-texture.frag') ... 

You cannot just take and apply the lighting shader directly to the scene, because then the shadows will be drawn on top of everything, so I will create a separate sprite into which I will draw the background and obstacles using RenderTexture (a special texture that allows you to draw any other PIXI object) same), then apply our filter to it.

  var lightingRT = new PIXI.RenderTexture(renderer, WIDTH, HEIGHT); var lightingSprite = new PIXI.Sprite(lightingRT); var filter = createSMapFilter(); lightingSprite.filters = [filter]; ... stage.addChild(lightingSprite); 

Well, drawing, in the animate () function:

  lightingRT.render(shadowCasters, null, true); // (shadowCasters   PIXI, null    (), true    ) 

It remains only to declare the function createSMapFilter:

  function createSMapFilter() { var SMapFilter = new PIXI.AbstractFilter(null, PIXI.loader.resources.glslShadowTexture.data, {}); // (  ,    ,      (uniforms)) return SMapFilter; } 

All obstacles will be painted in red:



Step 3: Create a shadow map


Now that the filter is working, it can be rewritten and expanded. First of all, from a simple shader it should be turned into a “combo filter”, which will contain other shaders. For this stage, you will need two - one empty, outputting the texture as it is and one for the shadow map. Also, it will contain a shadow map and another texture where we will render the shadowCasters container (for those who are lost, this is a container with obstacles.) Perhaps, I will simply introduce a new function “createSMapFilter ()” in its entirety:

  function createSMapFilter() { var CONST_LIGHTS_COUNT = 2; var SMapFilter = new PIXI.AbstractFilter(null, null, { // ,    ,   "" viewResolution: {type: '2fv', value: [WIDTH, HEIGHT]} //     view , rtSize: {type: '2fv', value: [1024, CONST_LIGHTS_COUNT]} //    . , uAmbient: {type: '4fv', value: [.0, .0, .0, .0]} //   " "   . }); //     /  : for (var i = 0; i < CONST_LIGHTS_COUNT; ++i) { SMapFilter.uniforms['uLightPosition[' + i + ']'] = {type: '4fv', value: [0, 0, 256, .3]}; // , ,     "falloff"    SMapFilter.uniforms['uLightColor[' + i + ']'] = {type: '4fv', value: [1, 1, 1, .3]}; // r, g, b,       . } //   PIXI ,      SMapFilter.renderTarget = new PIXI.RenderTarget( renderer.gl , SMapFilter.uniforms.rtSize.value[0] , SMapFilter.uniforms.rtSize.value[1] , PIXI.SCALE_MODES.LINEAR , 1); SMapFilter.renderTarget.transform = new PIXI.Matrix() .scale(SMapFilter.uniforms.rtSize.value[0] / WIDTH , SMapFilter.uniforms.rtSize.value[1] / HEIGHT); //       : SMapFilter.shadowCastersRT = new PIXI.RenderTexture(renderer, WIDTH, HEIGHT); SMapFilter.uniforms.uShadowCastersTexture = { type: 'sampler2D', value: SMapFilter.shadowCastersRT }; //    : SMapFilter.render = function (group) { SMapFilter.shadowCastersRT.render(group, null, true); }; //  ,   ; SMapFilter.testFilter = new PIXI.AbstractFilter(null, "precision highp float;" + "varying vec2 vTextureCoord;" + "uniform sampler2D uSampler;" + "void main(void) {gl_FragColor = texture2D(uSampler, vTextureCoord);}"); // ,   renderTarget  . var filterShadowTextureSource = PIXI.loader.resources.glslShadowTexture.data; // CONST_LIGHTS_COUNT            uniform. filterShadowTextureSource = filterShadowTextureSource.replace(/CONST_LIGHTS_COUNT/g, CONST_LIGHTS_COUNT); //       uniforms,   WebGL (       ,      ) var filterShadowTextureUniforms = Object.keys(SMapFilter.uniforms).reduce(function (c, k) { c[k] = { type: SMapFilter.uniforms[k].type , value: SMapFilter.uniforms[k].value }; return c; }, {}); SMapFilter.filterShadowTexture = new PIXI.AbstractFilter( null , filterShadowTextureSource , filterShadowTextureUniforms ); SMapFilter.applyFilter = function (renderer, input, output) { SMapFilter.filterShadowTexture.applyFilter(renderer, input, SMapFilter.renderTarget, true); SMapFilter.testFilter.applyFilter(renderer, SMapFilter.renderTarget, output); //     . }; return SMapFilter; } 

It should be noted that filterShadowTexture does not use input. Instead, it receives data through uniform uShadowCastersTexture. I made it so as not to bother with another renderTarget.

You can then update the interactive in the animate function:

  //  uniforms   filter.uniforms['uLightPosition[0]'].value[0] = lights[0].x; filter.uniforms['uLightPosition[0]'].value[1] = lights[0].y; filter.uniforms['uLightPosition[1]'].value[0] = lights[1].x; filter.uniforms['uLightPosition[1]'].value[1] = lights[1].y; //      renderTexture  . filter.render(shadowCasters); 

And finally, start writing the shader:

 //     : precision mediump float; //  uniform' varying vec2 vTextureCoord; //  uniform sampler2D uSampler; //      ( ) uniform vec2 viewResolution; //   uniform vec2 rtSize; //  renderTarget uniform vec4 uLightPosition[CONST_LIGHTS_COUNT]; //x,y = , z =  uniform vec4 uLightColor[CONST_LIGHTS_COUNT]; //   uniform sampler2D uShadowCastersTexture; //      --    . const float PI = 3.14159265358979; const float STEPS = 256.0; const float THRESHOLD = .01; void main(void) { int lightnum = int(floor(vTextureCoord.y * float(CONST_LIGHTS_COUNT))); //      Y vec2 lightPosition; float lightSize; for (int i = 0; i < CONST_LIGHTS_COUNT; i += 1) { //        if (lightnum == i) { lightPosition = uLightPosition[i].xy / viewResolution; lightSize = uLightPosition[i].z / max(viewResolution.x, viewResolution.y); break; } } float dst = 1.0; //     for (float y = 0.0; y < STEPS; y += 1.0) { //   (  (y / STEPS))      float distance = (y / STEPS); //    float angle = vTextureCoord.x * (2.0 * PI); //    //        vec2 coord = vec2(cos(angle) * distance, sin(angle) * distance); coord *= (max(viewResolution.x, viewResolution.y) / viewResolution); //  coord += lightPosition; //    coord = clamp(coord, 0., 1.); //      vec4 data = texture2D(uShadowCastersTexture, coord); //   if (data.a > THRESHOLD) { //   ,     . dst = min(dst, distance); break; } } //    ,     0..1 gl_FragColor = vec4(vec3(0.0), dst / lightSize); } 

Especially there is nothing to comment on, maybe I will add something from the questions in the comments.

Such a mysterious image should work out here, but this is exactly what our shadow map looks like, because it is recorded in the alpha channel and superimposed on the stage.



Step 4: Shadowing the Shadow Map


Add a new file to the preload, now it will look like this:

  PIXI.loader .add('background', 'img/maze.png') .add('glslShadowTexture', 'glsl/smap-shadow-texture.frag') .add('glslShadowCast', 'glsl/smap-shadow-cast.frag') .once('complete', setup) .load(); 

And, in the createSMapFilter function, copy uniforms again, this time for the second shader:

  var filterShadowCastUniforms = Object.keys(SMapFilter.uniforms).reduce(function (c, k) { c[k] = { type: SMapFilter.uniforms[k].type , value: SMapFilter.uniforms[k].value }; return c; }, {}); 

Next we need to transfer to it the shadow map (which is contained in the renderTarget). I did not find how to do this, so I use the hack, presenting the renderTarget as Texture:

  filterShadowCastUniforms.shadowMapChannel = { type: 'sampler2D', value: { baseTexture: { hasLoaded: true , _glTextures: [SMapFilter.renderTarget.texture] } } }; 

Creating a shader:

  SMapFilter.filterShadowCast = new PIXI.AbstractFilter( null , PIXI.loader.resources.glslShadowCast.data.replace(/CONST_LIGHTS_COUNT/g, CONST_LIGHTS_COUNT) , filterShadowCastUniforms ); 

Modify applyFilter:

  SMapFilter.applyFilter = function (renderer, input, output) { SMapFilter.filterShadowTexture.applyFilter(renderer, input, SMapFilter.renderTarget, true); //SMapFilter.testFilter.applyFilter(renderer, SMapFilter.renderTarget, output); //     . SMapFilter.filterShadowCast.applyFilter(renderer, input, output); }; 

Once again, so that everyone understands that where he is going:

input is the texture of the entire scene that will be illuminated;
output is the same;
SMapFilter.renderTarget is a shadow map.

The first shader does not use input and writes a shadow map from uniform, and the second shader uses both input and a shadow map (in the form of a uniform).

Everything, it remains to deal with glsl / smal-shadow-cast.frag:

 precision mediump float; uniform sampler2D uSampler; varying vec2 vTextureCoord; uniform vec2 viewResolution; uniform sampler2D shadowMapChannel; uniform vec4 uAmbient; uniform vec4 uLightPosition[CONST_LIGHTS_COUNT]; uniform vec4 uLightColor[CONST_LIGHTS_COUNT]; const float PI = 3.14159265358979; //    ()    vec4 takeSample(in sampler2D texture, in vec2 coord, in float light) { return step(light, texture2D(texture, coord)); } //      ( !) vec4 blurFn(in sampler2D texture, in vec2 tc, in float light, in float iBlur) { float blur = iBlur / viewResolution.x; vec4 sum = vec4(0.0); sum += takeSample(texture, vec2(tc.x - 5.0*blur, tc.y), light) * 0.022657; sum += takeSample(texture, vec2(tc.x - 4.0*blur, tc.y), light) * 0.046108; sum += takeSample(texture, vec2(tc.x - 3.0*blur, tc.y), light) * 0.080127; sum += takeSample(texture, vec2(tc.x - 2.0*blur, tc.y), light) * 0.118904; sum += takeSample(texture, vec2(tc.x - 1.0*blur, tc.y), light) * 0.150677; sum += takeSample(texture, vec2(tc.x, tc.y), light) * 0.163053; sum += takeSample(texture, vec2(tc.x + 1.0*blur, tc.y), light) * 0.150677; sum += takeSample(texture, vec2(tc.x + 2.0*blur, tc.y), light) * 0.118904; sum += takeSample(texture, vec2(tc.x + 3.0*blur, tc.y), light) * 0.080127; sum += takeSample(texture, vec2(tc.x + 4.0*blur, tc.y), light) * 0.046108; sum += takeSample(texture, vec2(tc.x + 5.0*blur, tc.y), light) * 0.022657; return sum; } // ,   : void main() { //      vec4 color = vec4(0.0, 0.0, 0.0, 1.0); //      . float lightLookupHalfStep = (1.0 / float(CONST_LIGHTS_COUNT)) * .5; //       for (int lightNumber = 0; lightNumber < CONST_LIGHTS_COUNT; lightNumber += 1) { float lightSize = uLightPosition[lightNumber].z / max(viewResolution.x, viewResolution.y); float lightFalloff = min(0.99, uLightPosition[lightNumber].a); if (lightSize == 0.) { //   ,    . continue; } vec2 lightPosition = uLightPosition[lightNumber].xy / viewResolution; vec4 lightColor = uLightColor[lightNumber]; //      vec3 lightLuminosity = vec3(0.0); //  Y     . float yCoord = float(lightNumber) / float(CONST_LIGHTS_COUNT) + lightLookupHalfStep; //       vec2 toLight = vTextureCoord - lightPosition; //  toLight /= (max(viewResolution.x, viewResolution.y) / viewResolution); toLight /= lightSize; //      float light = length(toLight); //      (  ) float angleToPoint = atan(toLight.y, toLight.x); float angleCoordOnMap = angleToPoint / (2.0 * PI); vec2 samplePoint = vec2(angleCoordOnMap, yCoord); //     --   . float blur = smoothstep(0., 2., light); //  ,               float sum = blurFn(shadowMapChannel, samplePoint, light, blur).a; sum = max(sum, lightColor.a); lightLuminosity = lightColor.rgb * vec3(sum) * smoothstep(1.0, lightFalloff, light); //     ( ): color.rgb += lightLuminosity; } //   color = max(color, uAmbient); //     vec4 base = texture2D(uSampler, vTextureCoord); //      "" (     ) gl_FragColor = vec4(base.rgb * sqrt(color.rgb), 1.0); } 

We save and now, everything works:



Well, or you can switch to the step4 branch.

In conclusion, I want to say that I didn’t get what I wanted (I wanted cool and simple shadows, but I received an invaluable experience of writing shaders), but the result looks acceptable and can be used somewhere.

Source: https://habr.com/ru/post/272233/


All Articles