From c48a56be0405453b1f1d7437da82a8af8f201c1e Mon Sep 17 00:00:00 2001 From: Lauren Budorick Date: Wed, 30 Aug 2017 12:48:02 -0700 Subject: [PATCH] Preserve depth buffer between fill-extrusion layers + optimize render order Optimize extrusion rendering by rendering all textures before any other layers + preserve depth buffer between extrusion layers. --- src/render/draw_background.js | 4 +- src/render/draw_circle.js | 2 +- src/render/draw_fill.js | 9 +- src/render/draw_fill_extrusion.js | 95 +++------ src/render/draw_line.js | 2 +- src/render/draw_raster.js | 2 +- src/render/draw_symbol.js | 2 +- src/render/painter.js | 190 +++++++++++++----- src/render/render_texture.js | 55 +++++ src/style/style.js | 2 +- src/style/style_layer.js | 4 + .../style_layer/fill_extrusion_style_layer.js | 4 + .../multiple/expected.png | Bin 0 -> 3108 bytes .../multiple/style.json | 155 ++++++++++++++ yarn.lock | 8 +- 15 files changed, 399 insertions(+), 135 deletions(-) create mode 100644 src/render/render_texture.js create mode 100644 test/integration/render-tests/fill-extrusion-multiple/multiple/expected.png create mode 100644 test/integration/render-tests/fill-extrusion-multiple/multiple/style.json diff --git a/src/render/draw_background.js b/src/render/draw_background.js index 64c22178ddf..ba00f47e956 100644 --- a/src/render/draw_background.js +++ b/src/render/draw_background.js @@ -16,8 +16,8 @@ function drawBackground(painter: Painter, sourceCache: SourceCache, layer: Style const image = layer.paint['background-pattern']; const opacity = layer.paint['background-opacity']; - const isOpaque = !image && color[3] === 1 && opacity === 1; - if (painter.isOpaquePass !== isOpaque) return; + const pass = (!image && color[3] === 1 && opacity === 1) ? 'opaque' : 'translucent'; + if (painter.renderPass !== pass) return; gl.disable(gl.STENCIL_TEST); diff --git a/src/render/draw_circle.js b/src/render/draw_circle.js index 6d076e3ca84..bed8a212334 100644 --- a/src/render/draw_circle.js +++ b/src/render/draw_circle.js @@ -12,7 +12,7 @@ import type TileCoord from '../source/tile_coord'; module.exports = drawCircles; function drawCircles(painter: Painter, sourceCache: SourceCache, layer: CircleStyleLayer, coords: Array) { - if (painter.isOpaquePass) return; + if (painter.renderPass !== 'translucent') return; const gl = painter.gl; diff --git a/src/render/draw_fill.js b/src/render/draw_fill.js index b0a29f5db5b..5435a9c632b 100644 --- a/src/render/draw_fill.js +++ b/src/render/draw_fill.js @@ -14,15 +14,14 @@ function drawFill(painter: Painter, sourceCache: SourceCache, layer: FillStyleLa const gl = painter.gl; gl.enable(gl.STENCIL_TEST); - const isOpaque = - !layer.paint['fill-pattern'] && + const pass = (!layer.paint['fill-pattern'] && layer.isPaintValueFeatureConstant('fill-color') && layer.isPaintValueFeatureConstant('fill-opacity') && layer.paint['fill-color'][3] === 1 && - layer.paint['fill-opacity'] === 1; + layer.paint['fill-opacity'] === 1) ? 'opaque' : 'translucent'; // Draw fill - if (painter.isOpaquePass === isOpaque) { + if (painter.renderPass === pass) { // Once we switch to earcut drawing we can pull most of the WebGL setup // outside of this coords loop. painter.setDepthSublayer(1); @@ -30,7 +29,7 @@ function drawFill(painter: Painter, sourceCache: SourceCache, layer: FillStyleLa } // Draw stroke - if (!painter.isOpaquePass && layer.paint['fill-antialias']) { + if (painter.renderPass === 'translucent' && layer.paint['fill-antialias']) { painter.lineWidth(2); painter.depthMask(false); diff --git a/src/render/draw_fill_extrusion.js b/src/render/draw_fill_extrusion.js index 20cfdde4d26..0150a3ef1df 100644 --- a/src/render/draw_fill_extrusion.js +++ b/src/render/draw_fill_extrusion.js @@ -1,9 +1,6 @@ // @flow const glMatrix = require('@mapbox/gl-matrix'); -const VertexBuffer = require('../gl/vertex_buffer'); -const VertexArrayObject = require('./vertex_array_object'); -const PosArray = require('../data/pos_array'); const pattern = require('./pattern'); const mat3 = glMatrix.mat3; const mat4 = glMatrix.mat4; @@ -18,95 +15,51 @@ import type TileCoord from '../source/tile_coord'; module.exports = draw; function draw(painter: Painter, source: SourceCache, layer: FillExtrusionStyleLayer, coords: Array) { - if (painter.isOpaquePass) return; - if (layer.paint['fill-extrusion-opacity'] === 0) return; + if (painter.renderPass === '3d') { + const gl = painter.gl; - const gl = painter.gl; - gl.disable(gl.STENCIL_TEST); - gl.enable(gl.DEPTH_TEST); - painter.depthMask(true); - - // Create a new texture to which to render the extrusion layer. This approach - // allows us to adjust opacity on a per-layer basis (eliminating the interior - // walls per-feature opacity problem) - const texture = renderToTexture(gl, painter); + gl.disable(gl.STENCIL_TEST); + gl.enable(gl.DEPTH_TEST); - gl.clearColor(0, 0, 0, 0); - gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + painter.clearColor(); + painter.depthMask(true); - for (let i = 0; i < coords.length; i++) { - drawExtrusion(painter, source, layer, coords[i]); + for (let i = 0; i < coords.length; i++) { + drawExtrusion(painter, source, layer, coords[i]); + } + } else if (painter.renderPass === 'translucent') { + drawExtrusionTexture(painter, layer); } - - // Unbind the framebuffer as a render target and render it to the map - gl.bindFramebuffer(gl.FRAMEBUFFER, null); - renderTextureToMap(gl, painter, layer, texture); } -function renderToTexture(gl, painter) { - gl.activeTexture(gl.TEXTURE1); - - let texture = painter.viewportTexture; - if (!texture) { - texture = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, texture); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, painter.width, painter.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); - painter.viewportTexture = texture; - } else { - gl.bindTexture(gl.TEXTURE_2D, texture); - } +function drawExtrusionTexture(painter, layer) { + const renderedTexture = painter.prerenderedFrames[layer.id]; + if (!renderedTexture) return; - let fbo = painter.viewportFbo; - if (!fbo) { - fbo = gl.createFramebuffer(); - gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); - const depthRenderBuffer = gl.createRenderbuffer(); - gl.bindRenderbuffer(gl.RENDERBUFFER, depthRenderBuffer); - gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, painter.width, painter.height); - gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthRenderBuffer); - painter.viewportFbo = fbo; - } else { - gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); - } - - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); - - return texture; -} - -function renderTextureToMap(gl, painter, layer, texture) { + const gl = painter.gl; const program = painter.useProgram('extrusionTexture'); + gl.disable(gl.STENCIL_TEST); + gl.disable(gl.DEPTH_TEST); + gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, texture); + gl.bindTexture(gl.TEXTURE_2D, renderedTexture.texture); gl.uniform1f(program.uniforms.u_opacity, layer.paint['fill-extrusion-opacity']); - gl.uniform1i(program.uniforms.u_image, 1); + gl.uniform1i(program.uniforms.u_image, 0); const matrix = mat4.create(); mat4.ortho(matrix, 0, painter.width, painter.height, 0, 0, 1); gl.uniformMatrix4fv(program.uniforms.u_matrix, false, matrix); - gl.disable(gl.DEPTH_TEST); - gl.uniform2f(program.uniforms.u_world, gl.drawingBufferWidth, gl.drawingBufferHeight); - const array = new PosArray(); - array.emplaceBack(0, 0); - array.emplaceBack(1, 0); - array.emplaceBack(0, 1); - array.emplaceBack(1, 1); - const buffer = new VertexBuffer(gl, array); - - const vao = new VertexArrayObject(); - vao.bind(gl, program, buffer); + renderedTexture.vao.bind(gl, program, renderedTexture.buffer); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); - gl.enable(gl.DEPTH_TEST); + // Since this texture has been rendered, make it available for reuse in the next frame. + painter.viewportFrames.push(renderedTexture); + delete painter.prerenderedFrames[layer.id]; } function drawExtrusion(painter, source, layer, coord) { diff --git a/src/render/draw_line.js b/src/render/draw_line.js index 68395cddc67..8b12a5027ae 100644 --- a/src/render/draw_line.js +++ b/src/render/draw_line.js @@ -10,7 +10,7 @@ import type LineBucket from '../data/bucket/line_bucket'; import type TileCoord from '../source/tile_coord'; module.exports = function drawLine(painter: Painter, sourceCache: SourceCache, layer: LineStyleLayer, coords: Array) { - if (painter.isOpaquePass) return; + if (painter.renderPass !== 'translucent') return; painter.setDepthSublayer(0); painter.depthMask(false); diff --git a/src/render/draw_raster.js b/src/render/draw_raster.js index ebbf170daf3..fb6613c4515 100644 --- a/src/render/draw_raster.js +++ b/src/render/draw_raster.js @@ -11,7 +11,7 @@ import type TileCoord from '../source/tile_coord'; module.exports = drawRaster; function drawRaster(painter: Painter, sourceCache: SourceCache, layer: StyleLayer, coords: Array) { - if (painter.isOpaquePass) return; + if (painter.renderPass !== 'translucent') return; const gl = painter.gl; const source = sourceCache.getSource(); diff --git a/src/render/draw_symbol.js b/src/render/draw_symbol.js index a8531396e70..f24a459409e 100644 --- a/src/render/draw_symbol.js +++ b/src/render/draw_symbol.js @@ -16,7 +16,7 @@ import type TileCoord from '../source/tile_coord'; module.exports = drawSymbols; function drawSymbols(painter: Painter, sourceCache: SourceCache, layer: SymbolStyleLayer, coords: Array) { - if (painter.isOpaquePass) return; + if (painter.renderPass !== 'translucent') return; const drawAcrossEdges = !layer.layout['text-allow-overlap'] && diff --git a/src/render/painter.js b/src/render/painter.js index 219e95be187..68bfc7c3276 100644 --- a/src/render/painter.js +++ b/src/render/painter.js @@ -14,6 +14,7 @@ const PosArray = require('../data/pos_array'); const {ProgramConfiguration} = require('../data/program_configuration'); const shaders = require('../shaders'); const Program = require('./program'); +const RenderTexture = require('./render_texture'); const draw = { symbol: require('./draw_symbol'), @@ -35,6 +36,8 @@ import type LineAtlas from './line_atlas'; import type SpriteAtlas from '../symbol/sprite_atlas'; import type GlyphSource from '../symbol/glyph_source'; +export type RenderPass = '3d' | 'opaque' | 'translucent'; + type PainterOptions = { showOverdrawInspector: boolean, showTileBoundaries: boolean, @@ -60,8 +63,10 @@ class Painter { emptyProgramConfiguration: ProgramConfiguration; width: number; height: number; - viewportTexture: WebGLTexture; - viewportFbo: WebGLFramebuffer; + viewportFrames: Array; + prerenderedFrames: { [string]: ?RenderTexture }; + depthRbo: WebGLRenderbuffer; + depthRboAttached: boolean; _depthMask: boolean; tileExtentBuffer: VertexBuffer; tileExtentVAO: VertexArrayObject; @@ -79,7 +84,7 @@ class Painter { spriteAtlas: SpriteAtlas; glyphSource: GlyphSource; depthRange: number; - isOpaquePass: boolean; + renderPass: RenderPass; currentLayer: number; id: string; _showOverdrawInspector: boolean; @@ -90,6 +95,8 @@ class Painter { this.gl = gl; this.transform = transform; this._tileTextures = {}; + this.prerenderedFrames = {}; + this.viewportFrames = []; this.frameHistory = new FrameHistory(); @@ -117,13 +124,15 @@ class Painter { this.height = height * browser.devicePixelRatio; gl.viewport(0, 0, this.width, this.height); - if (this.viewportTexture) { - this.gl.deleteTexture(this.viewportTexture); - this.viewportTexture = null; + for (const frame of this.viewportFrames) { + this.gl.deleteTexture(frame.texture); + this.gl.deleteFramebuffer(frame.fbo); } - if (this.viewportFbo) { - this.gl.deleteFramebuffer(this.viewportFbo); - this.viewportFbo = null; + this.viewportFrames = []; + + if (this.depthRbo) { + this.gl.deleteRenderbuffer(this.depthRbo); + this.depthRbo = null; } } @@ -257,9 +266,6 @@ class Painter { this.frameHistory.record(Date.now(), this.transform.zoom, style.getTransition().duration); - this.clearColor(); - this.clearDepth(); - for (const id in this.style.sourceCaches) { const sourceCache = this.style.sourceCaches[id]; if (sourceCache.used) { @@ -267,62 +273,156 @@ class Painter { } } - this.showOverdrawInspector(options.showOverdrawInspector); + const layerIds = this.style._order; - this.depthRange = (style._order.length + 2) * this.numSublayers * this.depthEpsilon; + // 3D pass + // We first create a renderbuffer that we'll use to preserve depth + // results across 3D layers, then render each 3D layer to its own + // framebuffer/texture, which we'll use later in the translucent pass + // to render to the main framebuffer. By doing this before we render to + // the main framebuffer we won't have to do an expensive framebuffer + // restore mid-render pass. + // The most important distinction of the 3D pass is that we use the + // depth buffer in an entirely different way (to represent 3D space) + // than we do in the 2D pass (to preserve layer order). + this.renderPass = '3d'; + { + // We'll wait and only attach the depth renderbuffer if we think we're + // rendering something. + let first = true; + + let sourceCache; + let coords = []; + + for (let i = 0; i < layerIds.length; i++) { + const layer = this.style._layers[layerIds[i]]; + + if (!layer.has3DPass() || layer.isHidden(this.transform.zoom)) continue; + + if (layer.source !== (sourceCache && sourceCache.id)) { + sourceCache = this.style.sourceCaches[layer.source]; + coords = []; + + if (sourceCache) { + this.clearStencil(); + coords = sourceCache.getVisibleCoordinates(); + } - this.isOpaquePass = true; - this.renderPass(); - this.isOpaquePass = false; - this.renderPass(); + coords.reverse(); + } - if (this.options.showTileBoundaries) { - const sourceCache = this.style.sourceCaches[Object.keys(this.style.sourceCaches)[0]]; - if (sourceCache) { - draw.debug(this, sourceCache, sourceCache.getVisibleCoordinates()); + if (!coords.length) continue; + + this._setup3DRenderbuffer(); + + const renderTarget = this.viewportFrames.pop() || new RenderTexture(this); + renderTarget.bindWithDepth(this.depthRbo); + + if (first) { + this.clearDepth(); + first = false; + } + + this.renderLayer(this, (sourceCache: any), layer, coords); + + renderTarget.unbind(); + this.prerenderedFrames[layer.id] = renderTarget; } } - } - renderPass() { - const layerIds = this.style._order; + // Clear buffers in preparation for drawing to the main framebuffer + this.clearColor(); + this.clearDepth(); + + this.showOverdrawInspector(options.showOverdrawInspector); - let sourceCache; - let coords = []; + this.depthRange = (style._order.length + 2) * this.numSublayers * this.depthEpsilon; + + // Opaque pass + // Draw opaque layers top-to-bottom first. + this.renderPass = 'opaque'; + { + let sourceCache; + let coords = []; - this.currentLayer = this.isOpaquePass ? layerIds.length - 1 : 0; + this.currentLayer = layerIds.length - 1; - if (this.isOpaquePass) { if (!this._showOverdrawInspector) { this.gl.disable(this.gl.BLEND); } - } else { - this.gl.enable(this.gl.BLEND); - } - for (let i = 0; i < layerIds.length; i++) { - const layer = this.style._layers[layerIds[this.currentLayer]]; + for (this.currentLayer; this.currentLayer >= 0; this.currentLayer--) { + const layer = this.style._layers[layerIds[this.currentLayer]]; - if (layer.source !== (sourceCache && sourceCache.id)) { - sourceCache = this.style.sourceCaches[layer.source]; - coords = []; + if (layer.source !== (sourceCache && sourceCache.id)) { + sourceCache = this.style.sourceCaches[layer.source]; + coords = []; - if (sourceCache) { - this.clearStencil(); - coords = sourceCache.getVisibleCoordinates(); - if (sourceCache.getSource().isTileClipped) { - this._renderTileClippingMasks(coords); + if (sourceCache) { + this.clearStencil(); + coords = sourceCache.getVisibleCoordinates(); + if (sourceCache.getSource().isTileClipped) { + this._renderTileClippingMasks(coords); + } } } - if (!this.isOpaquePass) { + this.renderLayer(this, (sourceCache: any), layer, coords); + } + } + + // Translucent pass + // Draw all other layers bottom-to-top. + this.renderPass = 'translucent'; + { + let sourceCache; + let coords = []; + + this.gl.enable(this.gl.BLEND); + + this.currentLayer = 0; + + for (this.currentLayer; this.currentLayer < layerIds.length; this.currentLayer++) { + const layer = this.style._layers[layerIds[this.currentLayer]]; + + if (layer.source !== (sourceCache && sourceCache.id)) { + sourceCache = this.style.sourceCaches[layer.source]; + coords = []; + + if (sourceCache) { + this.clearStencil(); + coords = sourceCache.getVisibleCoordinates(); + if (sourceCache.getSource().isTileClipped) { + this._renderTileClippingMasks(coords); + } + } + coords.reverse(); } + + this.renderLayer(this, (sourceCache: any), layer, coords); } + } - this.renderLayer(this, (sourceCache: any), layer, coords); - this.currentLayer += this.isOpaquePass ? -1 : 1; + if (this.options.showTileBoundaries) { + const sourceCache = this.style.sourceCaches[Object.keys(this.style.sourceCaches)[0]]; + if (sourceCache) { + draw.debug(this, sourceCache, sourceCache.getVisibleCoordinates()); + } + } + } + + _setup3DRenderbuffer() { + // All of the 3D textures will use the same depth renderbuffer. + if (!this.depthRbo) { + const gl = this.gl; + this.depthRbo = gl.createRenderbuffer(); + gl.bindRenderbuffer(gl.RENDERBUFFER, this.depthRbo); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, this.width, this.height); + gl.bindRenderbuffer(gl.RENDERBUFFER, null); } + + this.depthRboAttached = true; } depthMask(mask: boolean) { diff --git a/src/render/render_texture.js b/src/render/render_texture.js new file mode 100644 index 00000000000..9bd6963073e --- /dev/null +++ b/src/render/render_texture.js @@ -0,0 +1,55 @@ +// @flow + +const VertexBuffer = require('../gl/vertex_buffer'); +const VertexArrayObject = require('./vertex_array_object'); +const PosArray = require('../data/pos_array'); + +import type Painter from './painter'; + +class RenderTexture { + gl: WebGLRenderingContext; + texture: WebGLTexture; + fbo: WebGLFramebuffer; + buffer: VertexBuffer; + vao: VertexArrayObject; + + constructor(painter: Painter) { + const gl = this.gl = painter.gl; + + const texture = this.texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, painter.width, painter.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + + gl.bindTexture(gl.TEXTURE_2D, null); + + const fbo = this.fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + + const array = new PosArray(); + array.emplaceBack(0, 0); + array.emplaceBack(1, 0); + array.emplaceBack(0, 1); + array.emplaceBack(1, 1); + this.buffer = new VertexBuffer(gl, array); + this.vao = new VertexArrayObject(); + } + + bindWithDepth(depthRbo: WebGLRenderbuffer) { + const gl = this.gl; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthRbo); + } + + unbind() { + const gl = this.gl; + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, null); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } +} + +module.exports = RenderTexture; diff --git a/src/style/style.js b/src/style/style.js index e5b3a845982..26f4fb08663 100644 --- a/src/style/style.js +++ b/src/style/style.js @@ -244,7 +244,7 @@ class Style extends Evented { this.light = new Light(this.stylesheet.light); } - _serializeLayers(ids) { + _serializeLayers(ids: Array) { return ids.map((id) => this._layers[id].serialize()); } diff --git a/src/style/style_layer.js b/src/style/style_layer.js index baccfe43b11..2084555bbdd 100644 --- a/src/style/style_layer.js +++ b/src/style/style_layer.js @@ -381,6 +381,10 @@ class StyleLayer extends Evented { style: {glyphs: true, sprite: true} })); } + + has3DPass() { + return false; + } } module.exports = StyleLayer; diff --git a/src/style/style_layer/fill_extrusion_style_layer.js b/src/style/style_layer/fill_extrusion_style_layer.js index 614c6e72f8d..0645378f328 100644 --- a/src/style/style_layer/fill_extrusion_style_layer.js +++ b/src/style/style_layer/fill_extrusion_style_layer.js @@ -39,6 +39,10 @@ class FillExtrusionStyleLayer extends StyleLayer { bearing, pixelsToTileUnits); return multiPolygonIntersectsMultiPolygon(translatedPolygon, geometry); } + + has3DPass() { + return this.paint['fill-extrusion-opacity'] !== 0 && this.layout['visibility'] !== 'none'; + } } module.exports = FillExtrusionStyleLayer; diff --git a/test/integration/render-tests/fill-extrusion-multiple/multiple/expected.png b/test/integration/render-tests/fill-extrusion-multiple/multiple/expected.png new file mode 100644 index 0000000000000000000000000000000000000000..42faa2bc3fdc3bc225845f66c7844407621d4cb7 GIT binary patch literal 3108 zcmcgueK^$V8h&RQF&s-q>?sk~F)W6ygO;LH!uVD(hgL?3j5JziiP~9>6sOuHhh!M! z`)gW}EYWmSQX+~9&D3dqg*g{pW7}5ed4HVkoUZfN{&D;>=Keju`+lDLdER%P(>`7< z>Z+?$5kl&2uG{?(BE!EVq_P}7I=<@;hYzybcBftOq}QvCKSW4v7=N3(F&y(Oura0g z+FEVZhH|~RTJ5}Z^;=X`M9aoDq{y`A&+2(-Z@BC`sXx-#oa7bMxZp}2eEuos{yl%9 zKhV(zTJK5K13YU|(`hC`qA#)#+7jb|qK%5woesFr_*GRDgwl3W5z@X*kxm#r)m-R$ z9YN;qCIZb!i2q_2=y$9mHC3Ln6})F-s@(sis-+jH%bW?W^53}1v=A~qOKso1{fgnc z@1OKiq%R1relmCE`vrvsVx@iH_@Gkp-vnO92>Imw&p;(<5${KpU)y%f*AH86{l-r3 z9z`1JfgFdUbqn{-545e4h3#$2pEFNAaRDV&1|r9fXkEiSe}|_ApD6#CG%pTBBVkw5 zHEW}et3 zQMppJPxp!ZM{iE>Iqnq$-`J4-%e1)5WcqLUFNcfsUu`lSb!xY{)NfuA{Ghb7&qa`^ z()2B}JqxtRNA%Cnu9a%}O=S7ixvMBNxnT|bGo1-N_07jM<-wo5AvacPah+xQ%|&_2 zuQsW~r=LGs(m$CiaiHw+Wr&cShJDqfGRexVLf0n^CJ%RL>)NQj%vR)@#aB~P z^r-#==Ra7Ag*ob2m}MMNa`x+U%>tYfJY!wO&!D>O&uxVKhDypFpQcoQi%};#4M{Um zHb-aLP@3$~o&xzvo+i|BDkqiM*Zd~pJa_huKg3;C>L<%17pr00`AsNE51Em4HKz;J zyAr_LBl?18t`z~yR=K>ijaATv?_cSjZbuYuoU^TQcM6S!VM?j5VpvS8cEy2)myq zSV?P2OKIXTx^#oY;mV4s7TtyD`~pGS#ayo-_R9!zFeu>Ri5zW}8_d4@)4SXKu}_Uw zyU^rn4Sb2-jDFnX?nKhgKB}p&u~Zi*Vs8KFQW&+f@8BfV_--ph6as*-VK&$l-Xt@_ z$qSHh3eYjvKkzo|?2k3e!nvNN&cn$O@kXjBv+pixd3c(*+McCLQv=b$?SD@DhC^bH zIZ*Mu4NVU1k-JZ|4CvgS&1{T_w3iLwx!)T}nlD}|%3=VtIQ|e@GL(;%%ax=PaB0|cz(dF!4V|QRz+uv<5GtBeAd&%*7MT#~43R%AMK(m#U#;ZV)7QZRK&9AnL{hXZ+L(f>N*3sBAD4#9m@g{EzdQ1SK@kh(x# zQ5P}kehD3%>WCO^9od&(FsBdW^9oaZ1r)7I6z;h|(Rf2}A52Jt8Ed_@Y4f-ZczXB1 z%LqwnKUr+IG*||3t)Qr##!*bTdJUl7qjeSIlub~NEo1=B$dh>^lLgS|_ttQ$W`p%& zY6H@iEm}Vr0HpjQIbte4tsYew1I-^M1#z8WI;sQl@4X()pMjBO81Y|1 z%rUYJBN@~xxULWjfvfP_MXVWBxTh>ljFc=PS{PZ2k(+`?kRr0k0b{Csh($-R8za?x zK+*#ikq)Q^RiDDh0Z9>*g7i$1jHqg}{@^;hBz^9{ z)onq?&yTGQ(cpJhWtr4;KWqVEC9!bn(R9Xx`UEIs85XwqcnR}hRTIK131Qa{!a3rI z=ecwWGM{@DyPkddqpfbA