One way to limit the resolution of your game is to have your camera render to a RenderTexture.
You can create a RenderTexture in script to match the current aspect ratio of your window/screen, or even adapt the resolution of the texture dynamically in response to performance measurements. Assign it to the target texture property of your camera to redirect your rendering to that texture.
When you first do this, you suddenly won't see anything in your game — all the content is being rendered to this off-screen texture instead of to the screen. So then we need to display that texture full-screen, which we can do in a couple of ways:
Add a UI canvas in ScreenSpace Overlay mode, and put a RawImage component under it that displays this texture. Or...
Add a textured quad to your scene, using an unlit material to display this texture. Put it in a layer your main camera doesn't see, then add a new camera that sees only that layer, framed to match the quad.
What this does is limit the expensive scene rendering steps to operate at the fixed resolution of your texture. Then at the end you do a cheap texture sampling operation to stretch that texture to the full output resolution. You'll get some blurring because of this stretching, of course — that's the trade off we're making to save rendering performance. But you can also use a custom shader in the last step to control the texture interpolation scheme used.
If you're using URP/LWRP, this technique is built in as the Render Scale parameter on the pipeline asset, under Quality. Set this to a value less than 1 to downscale the render target used for scene rendering, and automatically stretch it to the size of the screen, without needing to juggle the RenderTexture yourself. UI will still be rendered at full res on top of this stretched result.