renderFrame(frameState, target) {
    this.renderComplete = true;

    /**
     * TODO:
     *  maybe skip transition when not fully opaque
     *  decide if this.renderComplete is useful
     */

    const layerState = frameState.layerStatesArray[frameState.layerIndex];
    const viewState = frameState.viewState;
    const projection = viewState.projection;
    const viewResolution = viewState.resolution;
    const viewCenter = viewState.center;
    const pixelRatio = frameState.pixelRatio;

    const tileLayer = this.getLayer();
    const tileSource = tileLayer.getSource();
    const tileGrid = tileSource.getTileGridForProjection(projection);
    const z = tileGrid.getZForResolution(viewResolution, tileSource.zDirection);
    const tileResolution = tileGrid.getResolution(z);

    const sourceKey = tileSource.getKey();
    if (!this.renderedSourceKey_) {
      this.renderedSourceKey_ = sourceKey;
    } else if (this.renderedSourceKey_ !== sourceKey) {
      this.prependStaleKey(this.renderedSourceKey_);
      this.renderedSourceKey_ = sourceKey;
    }

    let frameExtent = frameState.extent;
    const tilePixelRatio = tileSource.getTilePixelRatio(pixelRatio);

    this.prepareContainer(frameState, target);

    // desired dimensions of the canvas in pixels
    const width = this.context.canvas.width;
    const height = this.context.canvas.height;

    const layerExtent =
      layerState.extent && fromUserExtent(layerState.extent, projection);
    if (layerExtent) {
      frameExtent = getIntersection(
        frameExtent,
        fromUserExtent(layerState.extent, projection),
      );
    }

    const dx = (tileResolution * width) / 2 / tilePixelRatio;
    const dy = (tileResolution * height) / 2 / tilePixelRatio;
    const canvasExtent = [
      viewCenter[0] - dx,
      viewCenter[1] - dy,
      viewCenter[0] + dx,
      viewCenter[1] + dy,
    ];

    /**
     * @type {TileLookup}
     */
    const tilesByZ = {};

    this.renderedTiles.length = 0;

    /**
     * Part 1: Enqueue tiles
     */

    const preload = tileLayer.getPreload();
    if (frameState.nextExtent) {
      const targetZ = tileGrid.getZForResolution(
        viewState.nextResolution,
        tileSource.zDirection,
      );
      const nextExtent = getRenderExtent(frameState, frameState.nextExtent);
      this.enqueueTiles(frameState, nextExtent, targetZ, tilesByZ, preload);
    }

    const renderExtent = getRenderExtent(frameState, frameExtent);
    this.enqueueTiles(frameState, renderExtent, z, tilesByZ, 0);
    if (preload > 0) {
      setTimeout(() => {
        this.enqueueTiles(
          frameState,
          renderExtent,
          z - 1,
          tilesByZ,
          preload - 1,
        );
      }, 0);
    }

    if (!(z in tilesByZ)) {
      return this.container;
    }

    /**
     * Part 2: Find alt tiles for those that are not yet loaded
     */

    const uid = getUid(this);
    const time = frameState.time;

    // look for cached tiles to use if a target tile is not ready
    for (const tile of tilesByZ[z]) {
      const tileState = tile.getState();
      if (tileState === TileState.EMPTY) {
        continue;
      }
      const tileCoord = tile.tileCoord;

      if (tileState === TileState.LOADED) {
        const alpha = tile.getAlpha(uid, time);
        if (alpha === 1) {
          // no need to look for alt tiles
          tile.endTransition(uid);
          continue;
        }
      }
      if (tileState !== TileState.ERROR) {
        this.renderComplete = false;
      }

      const hasStaleTile = this.findStaleTile_(tileCoord, tilesByZ);
      if (hasStaleTile) {
        // use the stale tile before the new tile's transition has completed
        removeTileFromLookup(tilesByZ, tile, z);
        frameState.animate = true;
        continue;
      }

      // first look for child tiles (at z + 1)
      const coveredByChildren = this.findAltTiles_(
        tileGrid,
        tileCoord,
        z + 1,
        tilesByZ,
      );

      if (coveredByChildren) {
        continue;
      }

      // next look for parent tiles
      const minZoom = tileGrid.getMinZoom();
      for (let parentZ = z - 1; parentZ >= minZoom; --parentZ) {
        const coveredByParent = this.findAltTiles_(
          tileGrid,
          tileCoord,
          parentZ,
          tilesByZ,
        );

        if (coveredByParent) {
          break;
        }
      }
    }

    /**
     * Part 3: Render loaded tiles
     */

    const canvasScale =
      ((tileResolution / viewResolution) * pixelRatio) / tilePixelRatio;

    const context = this.getRenderContext(frameState);

    // set scale transform for calculating tile positions on the canvas
    composeTransform(
      this.tempTransform,
      width / 2,
      height / 2,
      canvasScale,
      canvasScale,
      0,
      -width / 2,
      -height / 2,
    );

    if (layerState.extent) {
      this.clipUnrotated(context, frameState, layerExtent);
    }

    if (!tileSource.getInterpolate()) {
      context.imageSmoothingEnabled = false;
    }

    this.preRender(context, frameState);

    /** @type {Array<number>} */
    const zs = Object.keys(tilesByZ).map(Number);
    zs.sort(ascending);

    let currentClip;
    const clips = [];
    const clipZs = [];
    for (let i = zs.length - 1; i >= 0; --i) {
      const currentZ = zs[i];
      const currentTilePixelSize = tileSource.getTilePixelSize(
        currentZ,
        pixelRatio,
        projection,
      );
      const currentResolution = tileGrid.getResolution(currentZ);
      const currentScale = currentResolution / tileResolution;
      const dx = currentTilePixelSize[0] * currentScale * canvasScale;
      const dy = currentTilePixelSize[1] * currentScale * canvasScale;
      const originTileCoord = tileGrid.getTileCoordForCoordAndZ(
        getTopLeft(canvasExtent),
        currentZ,
      );
      const originTileExtent = tileGrid.getTileCoordExtent(originTileCoord);
      const origin = applyTransform(this.tempTransform, [
        (tilePixelRatio * (originTileExtent[0] - canvasExtent[0])) /
          tileResolution,
        (tilePixelRatio * (canvasExtent[3] - originTileExtent[3])) /
          tileResolution,
      ]);
      const tileGutter =
        tilePixelRatio * tileSource.getGutterForProjection(projection);
      for (const tile of tilesByZ[currentZ]) {
        if (tile.getState() !== TileState.LOADED) {
          continue;
        }
        const tileCoord = tile.tileCoord;

        // Calculate integer positions and sizes so that tiles align
        const xIndex = originTileCoord[1] - tileCoord[1];
        const nextX = Math.round(origin[0] - (xIndex - 1) * dx);
        const yIndex = originTileCoord[2] - tileCoord[2];
        const nextY = Math.round(origin[1] - (yIndex - 1) * dy);
        const x = Math.round(origin[0] - xIndex * dx);
        const y = Math.round(origin[1] - yIndex * dy);
        const w = nextX - x;
        const h = nextY - y;
        const transition = zs.length === 1;

        let contextSaved = false;

        // Clip mask for regions in this tile that already filled by a higher z tile
        currentClip = [x, y, x + w, y, x + w, y + h, x, y + h];
        for (let i = 0, ii = clips.length; i < ii; ++i) {
          if (!transition && currentZ < clipZs[i]) {
            const clip = clips[i];
            if (
              intersects(
                [x, y, x + w, y + h],
                [clip[0], clip[3], clip[4], clip[7]],
              )
            ) {
              if (!contextSaved) {
                context.save();
                contextSaved = true;
              }
              context.beginPath();
              // counter-clockwise (outer ring) for current tile
              context.moveTo(currentClip[0], currentClip[1]);
              context.lineTo(currentClip[2], currentClip[3]);
              context.lineTo(currentClip[4], currentClip[5]);
              context.lineTo(currentClip[6], currentClip[7]);
              // clockwise (inner ring) for higher z tile
              context.moveTo(clip[6], clip[7]);
              context.lineTo(clip[4], clip[5]);
              context.lineTo(clip[2], clip[3]);
              context.lineTo(clip[0], clip[1]);
              context.clip();
            }
          }
        }
        clips.push(currentClip);
        clipZs.push(currentZ);

        this.drawTile(tile, frameState, x, y, w, h, tileGutter, transition);
        if (contextSaved) {
          context.restore();
        }
        this.renderedTiles.unshift(tile);

        // TODO: decide if this is necessary
        this.updateUsedTiles(frameState.usedTiles, tileSource, tile);
      }
    }

    this.renderedResolution = tileResolution;
    this.extentChanged =
      !this.renderedExtent_ || !equals(this.renderedExtent_, canvasExtent);
    this.renderedExtent_ = canvasExtent;
    this.renderedPixelRatio = pixelRatio;

    this.postRender(this.context, frameState);

    if (layerState.extent) {
      context.restore();
    }
    context.imageSmoothingEnabled = true;

    if (this.renderComplete) {
      /**
       * @param {import("../../Map.js").default} map Map.
       * @param {import("../../Map.js").FrameState} frameState Frame state.
       */
      const postRenderFunction = (map, frameState) => {
        const tileSourceKey = getUid(tileSource);
        const wantedTiles = frameState.wantedTiles[tileSourceKey];
        const tilesCount = wantedTiles ? Object.keys(wantedTiles).length : 0;
        this.updateCacheSize(tilesCount);
        this.tileCache_.expireCache();
      };

      frameState.postRenderFunctions.push(postRenderFunction);
    }

    return this.container;
  }
Render the layer.

The frame rendering logic has three parts:

 1. Enqueue tiles
 2. Find alt tiles for those that are not yet loaded
 3. Render loaded tiles

@param {import("../../Map.js").FrameState} frameState Frame state.
@param {HTMLElement} target Target that may be used to render content to.
@return {HTMLElement} The rendered element.
@override